231 lines
6.8 KiB
JavaScript
231 lines
6.8 KiB
JavaScript
// Browser network role: talks to the authoritative Node server over a WebSocket
|
|
// and exposes high-level events so the screens do not care about the transport.
|
|
|
|
export const DEFAULT_PORT = 27015;
|
|
export const DEFAULT_PATH = "/ws";
|
|
|
|
export class Network {
|
|
constructor() {
|
|
this.socket = null;
|
|
this.isAdmin = false;
|
|
this.localPlayerId = 0;
|
|
this.players = {};
|
|
this._handlers = {};
|
|
// The last full game state, with deltas merged into it. GameScreen always
|
|
// sees a complete snapshot even though the wire only carries what changed.
|
|
this._gameState = null;
|
|
this._loginFailure = "";
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
|
|
on(event, handler) {
|
|
if (!this._handlers[event]) this._handlers[event] = [];
|
|
this._handlers[event].push(handler);
|
|
}
|
|
|
|
_emit(event, ...args) {
|
|
for (const handler of this._handlers[event] || []) handler(...args);
|
|
}
|
|
|
|
isClientConnected() {
|
|
return this.localPlayerId !== 0;
|
|
}
|
|
|
|
defaultServerUrl() {
|
|
const location = window.location;
|
|
if (location.protocol === "http:" || location.protocol === "https:") {
|
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
return `${scheme}://${location.host}${DEFAULT_PATH}`;
|
|
}
|
|
return `ws://127.0.0.1:${DEFAULT_PORT}${DEFAULT_PATH}`;
|
|
}
|
|
|
|
// Accepts a bare hostname or a full ws(s):// or http(s):// URL.
|
|
normalizeUrl(host, port = DEFAULT_PORT) {
|
|
let url = String(host || "").trim().replace(/\/+$/, "");
|
|
if (url.startsWith("https://")) url = "wss://" + url.slice("https://".length);
|
|
else if (url.startsWith("http://")) url = "ws://" + url.slice("http://".length);
|
|
else if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
|
url = `ws://${url}:${port}`;
|
|
}
|
|
const afterScheme = url.slice(url.indexOf("://") + 3);
|
|
if (!afterScheme.includes("/")) url += DEFAULT_PATH;
|
|
return url;
|
|
}
|
|
|
|
connect(url) {
|
|
this.close();
|
|
this._loginFailure = "";
|
|
this.localPlayerId = 0;
|
|
this.isAdmin = false;
|
|
this._gameState = null;
|
|
return new Promise((resolve, reject) => {
|
|
let socket;
|
|
try {
|
|
socket = new WebSocket(url);
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
this.socket = socket;
|
|
this._connectResolve = resolve;
|
|
this._connectReject = reject;
|
|
socket.onopen = () => {
|
|
if (this._connectResolve) {
|
|
this._connectResolve();
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
};
|
|
socket.onerror = () => {
|
|
if (this._connectReject) {
|
|
this._connectReject(new Error("connection failed"));
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
}
|
|
};
|
|
socket.onclose = () => {
|
|
const failure = this._loginFailure;
|
|
this._loginFailure = "";
|
|
this.localPlayerId = 0;
|
|
this.isAdmin = false;
|
|
if (this._connectReject) {
|
|
this._connectReject(new Error("connection closed"));
|
|
this._connectResolve = null;
|
|
this._connectReject = null;
|
|
return;
|
|
}
|
|
if (failure) this._emit("session_failed", failure);
|
|
else this._emit("server_disconnected");
|
|
};
|
|
socket.onmessage = (event) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(event.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
this._handleMessage(message);
|
|
};
|
|
});
|
|
}
|
|
|
|
requestGameList(url) {
|
|
return this.connect(url).then(() => {
|
|
this._send({ t: "request_games" });
|
|
});
|
|
}
|
|
|
|
joinGame(url, name, password) {
|
|
return this.connect(url).then(() => {
|
|
this._send({ t: "login", name, password });
|
|
});
|
|
}
|
|
|
|
claimCiv(index) {
|
|
this._send({ t: "claim_civ", civ: index });
|
|
}
|
|
|
|
sendOrder(order) {
|
|
this._send({ t: "order", order });
|
|
}
|
|
|
|
sendChat(text, to = null) {
|
|
const value = String(text || "").trim();
|
|
if (value.length === 0) return;
|
|
const message = { t: "chat", text: value };
|
|
if (Number.isInteger(to) && to >= 0) message.to = to;
|
|
this._send(message);
|
|
}
|
|
|
|
close() {
|
|
if (!this.socket) return;
|
|
const socket = this.socket;
|
|
this.socket = null;
|
|
socket.onopen = null;
|
|
socket.onerror = null;
|
|
socket.onclose = null;
|
|
socket.onmessage = null;
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
_send(message) {
|
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
this.socket.send(JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
// Folds a delta into the last full state. A message without `delta` replaces
|
|
// it. `exploredAdded` carries only the tiles discovered since the last one, so
|
|
// the explored list (which only grows) is never re-sent in full; likewise
|
|
// `unitsUpdated`/`unitsRemoved` carry only the units that changed, so the
|
|
// whole army is not re-sent on every broadcast.
|
|
_mergeGameState(state) {
|
|
if (!state || !state.delta || !this._gameState) {
|
|
this._gameState = state;
|
|
return;
|
|
}
|
|
const merged = { ...this._gameState, ...state };
|
|
if (state.exploredAdded) {
|
|
merged.explored = (this._gameState.explored || []).concat(state.exploredAdded);
|
|
delete merged.exploredAdded;
|
|
}
|
|
if (state.unitsUpdated || state.unitsRemoved) {
|
|
const byId = new Map((this._gameState.units || []).map((unit) => [unit.id, unit]));
|
|
for (const unit of state.unitsUpdated || []) byId.set(unit.id, unit);
|
|
for (const id of state.unitsRemoved || []) byId.delete(id);
|
|
merged.units = Array.from(byId.values());
|
|
delete merged.unitsUpdated;
|
|
delete merged.unitsRemoved;
|
|
}
|
|
this._gameState = merged;
|
|
}
|
|
|
|
_handleMessage(message) {
|
|
switch (message.t) {
|
|
case "assigned":
|
|
this.localPlayerId = message.playerId;
|
|
this.isAdmin = !!message.admin;
|
|
this._emit("session_started", message);
|
|
break;
|
|
case "players":
|
|
this.players = message.players || {};
|
|
this._emit("players_updated", this.players);
|
|
break;
|
|
case "chat":
|
|
this._emit("chat_message", {
|
|
name: message.name || "",
|
|
civ: message.civ,
|
|
to: Number.isInteger(message.to) ? message.to : null,
|
|
text: message.text || "",
|
|
});
|
|
break;
|
|
case "game_list":
|
|
this._emit("game_list_received", message.games || []);
|
|
break;
|
|
case "login_rejected":
|
|
this._loginFailure = message.reason || "Login failed.";
|
|
this._emit("session_failed", this._loginFailure);
|
|
this.close();
|
|
break;
|
|
case "choose_civ":
|
|
this._emit("choose_civ_received", message.civs || []);
|
|
break;
|
|
case "civ_assigned":
|
|
this._emit("civ_assigned", message.civ);
|
|
break;
|
|
case "game_state":
|
|
this._mergeGameState(message.state);
|
|
this._emit("game_state_received", this._gameState);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|