UNPKG

swordfight-engine

Version:

A multiplayer sword fighting game engine with character management, round-based combat, and real-time multiplayer support

965 lines (958 loc) 30.1 kB
/** * swordfight-engine v1.6.21 * @license MIT */ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/classes/transports/MultiplayerTransport.js var MultiplayerTransport = class _MultiplayerTransport { constructor(game) { this.game = game; this.started = false; if (new.target === _MultiplayerTransport) { throw new Error("MultiplayerTransport is an abstract class and cannot be instantiated directly"); } } /** * Connect to a room/session * @param {string} _roomId - The room identifier * @returns {Promise<void>} */ async connect(_roomId) { throw new Error("connect() must be implemented by subclass"); } /** * Send a move to the opponent * @param {Object} _data - Move data { move: Object, round: number } */ sendMove(_data) { throw new Error("sendMove() must be implemented by subclass"); } /** * Register callback for receiving opponent's move * @param {Function} _callback - Callback function to handle received move */ getMove(_callback) { throw new Error("getMove() must be implemented by subclass"); } /** * Send player name to opponent * @param {Object} _data - Name data { name: string } */ sendName(_data) { throw new Error("sendName() must be implemented by subclass"); } /** * Register callback for receiving opponent's name * @param {Function} _callback - Callback function to handle received name */ getName(_callback) { throw new Error("getName() must be implemented by subclass"); } /** * Send character slug to opponent * @param {Object} _data - Character data { characterSlug: string } */ sendCharacter(_data) { throw new Error("sendCharacter() must be implemented by subclass"); } /** * Register callback for receiving opponent's character slug * @param {Function} _callback - Callback function to handle received character slug */ getCharacter(_callback) { throw new Error("getCharacter() must be implemented by subclass"); } /** * Disconnect from the session */ disconnect() { throw new Error("disconnect() must be implemented by subclass"); } /** * Get the number of connected peers * @returns {number} */ getPeerCount() { throw new Error("getPeerCount() must be implemented by subclass"); } /** * Check if room is full * @returns {boolean} */ isRoomFull() { return this.getPeerCount() >= 2; } }; // src/classes/transports/WebSocketTransport.js var WebSocketTransport = class extends MultiplayerTransport { constructor(game, options = {}) { super(game); this.serverUrl = options.serverUrl || "ws://localhost:8080"; this.ws = null; this.moveCallbacks = []; this.nameCallbacks = []; this.characterCallbacks = []; this.roomId = null; } /** * Connect to a WebSocket server and join a room * @param {string} roomId - The room identifier */ async connect(roomId) { this.roomId = roomId; return new Promise((resolve, reject) => { console.log("Connecting to WebSocket server:", this.serverUrl); this.ws = new WebSocket(this.serverUrl); this.ws.onopen = () => { console.log("Connected to WebSocket server"); this.ws.send(JSON.stringify({ type: "join", roomId })); }; this.ws.onmessage = (event) => { const message = JSON.parse(event.data); this._handleMessage(message); }; this.ws.onerror = (error) => { console.error("WebSocket error:", error); reject(error); }; this.ws.onclose = () => { console.log("WebSocket connection closed"); this.started = false; }; const joinListener = (event) => { const message = JSON.parse(event.data); if (message.type === "joined") { resolve(); this.ws.removeEventListener("message", joinListener); } else if (message.type === "error") { reject(new Error(message.message)); } }; this.ws.addEventListener("message", joinListener); }); } /** * Handle incoming WebSocket messages * @private */ _handleMessage(message) { switch (message.type) { case "joined": console.log("Successfully joined room:", message.roomId); break; case "peer-joined": { console.log("Peer joined the room"); this.started = true; const playerName = this._getPlayerName(); this.game.myCharacter.name = playerName; this.sendName({ name: playerName, characterSlug: this.game.myCharacterSlug }); if (typeof document !== "undefined") { const startEvent = new CustomEvent("start", { detail: { game: this.game } }); document.dispatchEvent(startEvent); } break; } case "move": this.moveCallbacks.forEach((callback) => callback(message.data)); break; case "name": this.nameCallbacks.forEach((callback) => callback(message.data)); break; case "character": this.characterCallbacks.forEach((callback) => callback(message.data)); break; case "room-full": console.error("Room is full"); if (typeof document !== "undefined") { const roomFullEvent = new CustomEvent("roomFull"); document.dispatchEvent(roomFullEvent); } break; case "peer-left": console.log("Peer left the room"); this.started = false; break; default: console.warn("Unknown message type:", message.type); } } /** * Get player name from storage or character * @private */ _getPlayerName() { if (typeof localStorage !== "undefined" && localStorage.getItem("playerName")) { return localStorage.getItem("playerName"); } return this.game.myCharacter.name; } /** * Send a move to the opponent via WebSocket * @param {Object} data - Move data */ sendMove(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "move", roomId: this.roomId, data })); } } /** * Send player name to opponent via WebSocket * @param {Object} data - Name data */ sendName(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "name", roomId: this.roomId, data })); } } /** * Register callback for receiving opponent's move * @param {Function} callback - Callback function */ getMove(callback) { this.moveCallbacks.push(callback); } /** * Register callback for receiving opponent's name * @param {Function} callback - Callback function */ getName(callback) { this.nameCallbacks.push(callback); } /** * Send character slug to opponent via WebSocket * @param {Object} data - Character data { characterSlug: string } */ sendCharacter(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "character", roomId: this.roomId, data })); } } /** * Register callback for receiving opponent's character slug * @param {Function} callback - Callback function */ getCharacter(callback) { this.characterCallbacks.push(callback); } /** * Get the number of connected peers * @returns {number} */ getPeerCount() { return this.started ? 1 : 0; } /** * Disconnect from the WebSocket */ disconnect() { if (this.ws) { this.ws.send(JSON.stringify({ type: "leave", roomId: this.roomId })); this.ws.close(); this.ws = null; } this.started = false; this.moveCallbacks = []; this.nameCallbacks = []; this.characterCallbacks = []; } }; // src/classes/transports/DurableObjectTransport.js var DurableObjectTransport = class extends MultiplayerTransport { constructor(game, options = {}) { super(game); this.serverUrl = options.serverUrl; if (!this.serverUrl) { throw new Error("DurableObjectTransport requires options.serverUrl (your CloudFlare Worker URL)"); } this.ws = null; this.moveCallbacks = []; this.nameCallbacks = []; this.characterCallbacks = []; this.roomId = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this.reconnectDelay = options.reconnectDelay || 1e3; } /** * Connect to a CloudFlare Worker and join a game room * @param {string} roomId - The room identifier */ async connect(roomId) { this.roomId = roomId; return new Promise((resolve, reject) => { const wsUrl = `${this.serverUrl}?room=${encodeURIComponent(roomId)}`; if (window.logging) { console.log("Connecting to CloudFlare Worker:", wsUrl); } try { this.ws = new WebSocket(wsUrl); } catch (error) { console.error("Failed to create WebSocket:", error); reject(error); return; } const timeout = setTimeout(() => { if (this.ws.readyState !== WebSocket.OPEN) { this.ws.close(); reject(new Error("Connection timeout")); } }, 1e4); this.ws.onopen = () => { clearTimeout(timeout); if (window.logging) { console.log("Connected to CloudFlare Worker"); } const playerName = this._getPlayerName(); this.game.myCharacter.name = playerName; this.sendName({ name: playerName, characterSlug: this.game.myCharacterSlug }); resolve(); }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); if (window.logging) { console.log("[DurableObjectTransport] Received message:", message.type, message); } this._handleMessage(message); } catch (error) { console.error("Error parsing message:", error); } }; this.ws.onerror = (error) => { clearTimeout(timeout); console.error("WebSocket error:", error); reject(error); }; this.ws.onclose = (event) => { clearTimeout(timeout); if (window.logging) { console.log("WebSocket connection closed", event.code, event.reason); } this.started = false; if (event.code !== 1e3 && this.reconnectAttempts < this.maxReconnectAttempts) { this._attemptReconnect(); } }; }); } /** * Attempt to reconnect to the server * @private */ _attemptReconnect() { this.reconnectAttempts++; if (window.logging) { console.log(`Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); } setTimeout(() => { this.connect(this.roomId).catch((error) => { console.error("Reconnection failed:", error); if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (typeof document !== "undefined") { const disconnectEvent = new CustomEvent("connectionLost"); document.dispatchEvent(disconnectEvent); } } }); }, this.reconnectDelay * this.reconnectAttempts); } /** * Handle incoming WebSocket messages * @private */ _handleMessage(message) { switch (message.type) { case "history": if (window.logging) { console.log(`Replaying ${message.messages.length} buffered messages`); } message.messages.forEach((bufferedMessage) => { this._handleMessage(bufferedMessage); }); break; case "peer-joined": if (window.logging) { console.log("Peer joined the room"); } this.started = true; if (typeof document !== "undefined") { const startEvent = new CustomEvent("start", { detail: { game: this.game } }); document.dispatchEvent(startEvent); } break; case "move": if (window.logging) { console.log("[DurableObjectTransport] Processing move, callbacks:", this.moveCallbacks.length); } this.moveCallbacks.forEach((callback) => { try { if (window.logging) { console.log("[DurableObjectTransport] Calling move callback with data:", message.data); } callback(message.data); } catch (error) { console.error("Error in move callback:", error); } }); break; case "name": if (window.logging) { console.log("[DurableObjectTransport] Processing name, callbacks:", this.nameCallbacks.length); } this.nameCallbacks.forEach((callback) => { try { if (window.logging) { console.log("[DurableObjectTransport] Calling name callback with data:", message.data); } callback(message.data); } catch (error) { console.error("Error in name callback:", error); } }); break; case "character": if (window.logging) { console.log("[DurableObjectTransport] Processing character, callbacks:", this.characterCallbacks.length); } this.characterCallbacks.forEach((callback) => { try { if (window.logging) { console.log("[DurableObjectTransport] Calling character callback with data:", message.data); } callback(message.data); } catch (error) { console.error("Error in character callback:", error); } }); break; case "room-full": console.error("Room is full"); if (typeof document !== "undefined") { const roomFullEvent = new CustomEvent("roomFull"); document.dispatchEvent(roomFullEvent); } break; case "peer-left": if (window.logging) { console.log("Peer left the room"); } this.started = false; if (typeof document !== "undefined") { const peerLeftEvent = new CustomEvent("peerLeft"); document.dispatchEvent(peerLeftEvent); } break; case "error": console.error("Server error:", message.message); break; default: console.warn("Unknown message type:", message.type); } } /** * Get player name from storage or character * @private */ _getPlayerName() { if (typeof localStorage !== "undefined" && localStorage.getItem("playerName")) { return localStorage.getItem("playerName"); } return this.game.myCharacter.name; } /** * Send a move to the opponent * @param {Object} data - Move data { move: Object, round: number } */ sendMove(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "move", data })); } else { console.error("Cannot send move: WebSocket not open"); } } /** * Register callback for receiving opponent's move * @param {Function} callback - Callback function to handle received move */ getMove(callback) { if (window.logging) { console.log("DurableObjectTransport: Registering move callback. Total callbacks:", this.moveCallbacks.length + 1); } this.moveCallbacks.push(callback); } /** * Send player name to opponent * @param {Object} data - Name data { name: string } */ sendName(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "name", data })); } else { console.error("Cannot send name: WebSocket not open"); } } /** * Register callback for receiving opponent's name * @param {Function} callback - Callback function to handle received name */ getName(callback) { this.nameCallbacks.push(callback); } /** * Send character slug to opponent * @param {Object} data - Character data { characterSlug: string } */ sendCharacter(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "character", data })); } else { console.error("Cannot send character: WebSocket not open"); } } /** * Register callback for receiving opponent's character slug * @param {Function} callback - Callback function to handle received character slug */ getCharacter(callback) { this.characterCallbacks.push(callback); } /** * Disconnect from the session */ disconnect() { if (this.ws) { this.ws.close(1e3, "Client disconnected"); this.ws = null; } this.started = false; this.moveCallbacks = []; this.nameCallbacks = []; this.characterCallbacks = []; } /** * Get the number of connected peers * Note: Durable Objects don't expose peer count directly * We track this based on peer-joined/peer-left events * @returns {number} */ getPeerCount() { return this.started ? 1 : 0; } }; // src/classes/Moves.js var Moves = class { constructor(character, result = []) { /** * getMoveObject */ __publicField(this, "getMoveObject", (id) => { return this.moves.find((move) => move.id === id); }); this.character = character; this.result = result; this.moves = this.character.moves; this.filteredMoves = this.filteredMoves(); } /** * @method getMoves * * @description * Get the moves */ getMoves() { return this.character.moves; } /** * filteredMoves */ filteredMoves() { return this.moves.filter((move) => { if (this.result.allowOnly && !this.result.allowOnly.includes(move.name) && !this.result.allowOnly.includes(move.tag)) { return false; } if (move.requiresWeapon) { const availableWeapons = this.character.weapons || []; if (typeof move.requiresWeapon === "string") { const requiredWeapon = availableWeapons.find((w) => w.name === move.requiresWeapon); if (!requiredWeapon) { return false; } if (move.requiresAmmo) { const hasAmmo = requiredWeapon.ammo === null || requiredWeapon.ammo > 0; if (!hasAmmo) { return false; } } } else { if (availableWeapons.length === 0) { return false; } } } if (!this.character.shield && move.requiresShield) { return false; } const hasWeapons = this.character.weapons && this.character.weapons.length > 0; const hasDroppedWeapons = this.character.droppedWeapons && this.character.droppedWeapons.length > 0; if (move.name === "Retrieve Weapon" && (hasWeapons || !hasDroppedWeapons || this.character.weaponDestroyed)) { return false; } if (move.range !== this.result.range) { return false; } if (this.result.restrict.includes(move.type)) { return false; } if (this.result.restrict.includes(move.tag)) { return false; } if (this.result.restrict.includes(move.name)) { return false; } return true; }); } }; // src/classes/BonusCalculator.js var BonusCalculator = class { /** * Calculate the bonus to apply to a move based on previous round bonus data * @param {object} move - The current move being made * @param {string} move.type - The move type (e.g., 'strong', 'defensive') * @param {string} move.tag - The move tag (e.g., 'Down Swing', 'Thrust') * @param {string} move.name - The move name (e.g., 'Smash', 'Block') * @param {array} previousRoundBonus - The bonus array from the previous round's result * @returns {number} The bonus value to apply to this move * * @example * // Previous round resulted in a bonus for 'strong' moves * const previousBonus = [{ strong: 2 }]; * const move = { type: 'strong', tag: 'Down Swing', name: 'Smash' }; * const bonus = BonusCalculator.calculateBonus(move, previousBonus); * // Returns: 2 * * @example * // Previous round resulted in multiple bonuses * const previousBonus = [{ strong: 2, 'Down Swing': 1 }]; * const move = { type: 'strong', tag: 'Down Swing', name: 'Smash' }; * const bonus = BonusCalculator.calculateBonus(move, previousBonus); * // Returns: 3 (2 from type + 1 from tag) * * @example * // No bonus applies * const previousBonus = [{ defensive: 1 }]; * const move = { type: 'strong', tag: 'Down Swing', name: 'Smash' }; * const bonus = BonusCalculator.calculateBonus(move, previousBonus); * // Returns: 0 */ static calculateBonus(move, previousRoundBonus) { let bonus = 0; if (!previousRoundBonus || !Array.isArray(previousRoundBonus) || !previousRoundBonus.length) { return 0; } if (!move || typeof move !== "object") { return 0; } previousRoundBonus.forEach((bonusObject) => { if (!bonusObject || typeof bonusObject !== "object") { return; } for (const key in bonusObject) { if (move.type === key || move.tag === key || move.name === key) { const bonusValue = parseFloat(bonusObject[key]); if (!isNaN(bonusValue)) { bonus += bonusValue; } } } }); return bonus; } /** * Get the next round bonus that will be provided by a result * @param {object} result - The round result object * @param {array} [result.bonus] - Optional bonus array to apply next round * @returns {array|number} The bonus array for next round, or 0 if none * bonus += bonusValue; } * const result = { bonus: [{ strong: 2 }] }; * const nextBonus = BonusCalculator.getNextRoundBonus(result); * // Returns: [{ strong: 2 }] * * @example * const result = { score: 5 }; // No bonus property * const nextBonus = BonusCalculator.getNextRoundBonus(result); * // Returns: 0 */ static getNextRoundBonus(result) { if (!result || !result.bonus) { return []; } return Array.isArray(result.bonus) ? result.bonus : [result.bonus]; } /** * Calculate total score including bonus * @param {number|string} baseScore - The base score from hitting * @param {number|string} moveModifier - The move's modifier * @param {number|string} bonus - The bonus to apply * @returns {number} The total score (minimum 0) * * @example * const total = BonusCalculator.calculateTotalScore(5, 2, 3); * // Returns: 10 * * @example * // Negative scores become 0 * const total = BonusCalculator.calculateTotalScore(1, -3, 0); * // Returns: 0 */ static calculateTotalScore(baseScore, moveModifier, bonus) { const score = parseFloat(baseScore); const modifier = parseFloat(moveModifier); const bonusValue = parseFloat(bonus); if (isNaN(score)) { return 0; } const mod = isNaN(modifier) ? 0 : modifier; const bon = isNaN(bonusValue) ? 0 : bonusValue; let totalScore = score + mod + bon; if (totalScore < 0) { totalScore = 0; } return totalScore; } }; // src/classes/transports/ComputerTransport.js var ComputerTransport = class extends MultiplayerTransport { constructor(game, options = {}) { super(game); this.startDelay = options.startDelay || 3e3; this.moveCallbacks = []; this.nameCallbacks = []; this.selectedOpponentSlug = null; this.gameEnded = false; this.pendingMoveTimeout = null; this.preparingForRound = null; if (typeof document !== "undefined") { document.addEventListener("victory", () => { this._stopPreparing(); }); document.addEventListener("defeat", () => { this._stopPreparing(); }); document.addEventListener("setup", () => { if (!this.gameEnded && this.game.opponentsCharacter && this.game.opponentsCharacter.moves) { this._prepareMove(); } }); } } /** * Connect to the "computer opponent session" * @param {string} _roomId - Not used for computer opponent * @returns {Promise<void>} */ async connect(_roomId) { const availableCharacters = await this.game.CharacterLoader.getAvailableCharacters(); const computerCharacters = availableCharacters.filter((slug) => !slug.includes("fighter") && slug !== "human-monk"); this.selectedOpponentSlug = computerCharacters[Math.floor(Math.random() * computerCharacters.length)]; return new Promise((resolve) => { setTimeout(() => { this.started = true; if (typeof document !== "undefined") { const startEvent = new CustomEvent("start", { detail: { game: this.game } }); document.dispatchEvent(startEvent); } resolve(); setTimeout(() => { this._triggerNameCallbacks(); setTimeout(() => { if (this.game.opponentsCharacter && this.game.opponentsCharacter.moves) { this._prepareMove(); } }, 100); }, 0); }, this.startDelay); }); } /** * Trigger all registered name callbacks with opponent data * @private */ _triggerNameCallbacks() { const data = { name: "Computer", characterSlug: this.selectedOpponentSlug }; this.nameCallbacks.forEach((callback) => { try { callback(data); } catch (error) { console.error("Error in name callback:", error); } }); } /** * Register callback for receiving opponent's move * @param {Function} callback - Callback function to handle opponent's move */ getMove(callback) { if (!this.moveCallbacks.includes(callback)) { this.moveCallbacks.push(callback); } } /** * Register callback for receiving opponent's name * @param {Function} callback - Callback function to handle received name */ getName(callback) { if (!this.nameCallbacks.includes(callback)) { this.nameCallbacks.push(callback); } } /** * Prepare and deliver computer's move after thinking delay * Called automatically when a new round starts * @private */ _prepareMove() { const currentRound = this.game.roundNumber; if (this.preparingForRound === currentRound) { return; } this.preparingForRound = currentRound; if (window.logging) { console.log(`Computer preparing move for round ${currentRound}`); } const thinkingTime = currentRound === 0 ? 0 : 3e3 + Math.floor(Math.random() * 6e3); this.pendingMoveTimeout = setTimeout(() => { this.pendingMoveTimeout = null; const previousRound = this.game.roundNumber > 0 ? this.game.rounds[this.game.roundNumber - 1] : null; const result = previousRound?.opponentsRoundData?.result || { range: this.game.opponentsCharacter.moves[0].range, restrict: [], allowOnly: null }; const moves = new Moves(this.game.opponentsCharacter, result); let move = moves.filteredMoves[Math.floor(Math.random() * moves.filteredMoves.length)]; if (this.game.opponentsCharacter.droppedWeapons && this.game.opponentsCharacter.droppedWeapons.length > 0 && Math.random() < 0.333) { const retrieveMove = moves.filteredMoves.find((mv) => mv.name === "Retrieve Weapon"); if (retrieveMove) { move = retrieveMove; } } const previousRoundData = this.game.roundNumber > 0 ? this.game.rounds[this.game.roundNumber - 1] : null; const opponentPreviousBonus = previousRoundData?.opponentsRoundData?.nextRoundBonus || []; const bonusMoves = moves.filteredMoves.filter((mv) => { const bonus = BonusCalculator.calculateBonus(mv, opponentPreviousBonus); return bonus > 0; }); if (bonusMoves.length > 0 && Math.random() < 0.333) { move = bonusMoves[Math.floor(Math.random() * bonusMoves.length)]; } if (this.gameEnded) { return; } const data = { move }; this.moveCallbacks.forEach((callback) => { try { callback(data); } catch (error) { console.error("Error in move callback:", error); } }); }, thinkingTime); } /** * Send player's move (computer is already thinking independently) * @param {Object} _data - Move data { move: Object, round: number } */ sendMove(_data) { } /** * sendName * Send the name (not used in single-player, but included for consistency) */ sendName(name) { return name; } /** * sendCharacter * Send the character slug (not used in single-player, but included for consistency) */ sendCharacter(data) { return data; } /** * getCharacter * Get the character slug (not used in single-player, but included for consistency) */ getCharacter(callback) { callback({ characterSlug: this.selectedOpponentSlug }); } /** * Get the number of connected peers * @returns {number} Always returns 1 for computer opponent */ getPeerCount() { return this.started ? 1 : 0; } /** * Stop preparing moves and cancel any pending timeouts * @private */ _stopPreparing() { this.gameEnded = true; if (this.pendingMoveTimeout) { clearTimeout(this.pendingMoveTimeout); this.pendingMoveTimeout = null; } } /** * Disconnect from the computer opponent session */ disconnect() { this.started = false; this._stopPreparing(); this.moveCallbacks = []; this.nameCallbacks = []; } }; export { ComputerTransport, DurableObjectTransport, MultiplayerTransport, WebSocketTransport };