UNPKG

programming-game

Version:

The client for programming game, an mmorpg that you interact with entirely through code.

796 lines (773 loc) 26.1 kB
import { constants } from "./constants"; import { getHandlers } from "./get-handlers"; import { items } from "./items"; import { recipes } from "./recipes"; import { BatchedEvents, Boundary, ClientSideNPC, ClientSidePlayer, ClientSideUnit, EventMap, GameObject, InstanceName, Intent, PlayersSeekingParty, RawEvents, OnTick, } from "./types"; import { entries } from "./utils"; let localItems = items; let localConstants = constants; let localRecipes = recipes; const isPlayerUnit = (unit?: ClientSideUnit): unit is ClientSidePlayer => { return !!unit && unit.type === "player"; }; const isEmptyObject = (obj: object) => { for (const _key in obj) { if (obj.hasOwnProperty(_key)) { return false; } } return true; }; export type OnEvent = <T extends keyof RawEvents>( instance: InstanceName, charId: string, eventName: T, evt: RawEvents[T] ) => void; export type SetIntent = (intent: { c: string; // character id i: InstanceName; intent: Intent; unitId: string; // unit id }) => void; /** * Base class for the client. * This class cares about the events, but NOT the implementation of the events. * Helpful for testing the server/client integration without firing up websockets. */ export class BaseClient { private onTick: OnTick; private heartBeatInterval?: NodeJS.Timeout; private setIntent: SetIntent; private onEvent?: OnEvent; constructor({ onTick, onEvent, setIntent, tickInterval, }: { onTick: OnTick; onEvent?: OnEvent; setIntent: SetIntent; /** * Controls how often onTick is called when there's no new data from the server. */ tickInterval: number; }) { this.onTick = onTick; this.onEvent = onEvent; this.setIntent = setIntent; this.heartBeatInterval = setInterval(this.runOnTick, tickInterval); } private lastOnTick = Date.now(); protected innerState: { gameTime: number; instances: { [key: string]: { time: number; characters: Record< string, { units: Record<string, ClientSideUnit>; gameObjects: Record<string, GameObject>; } >; playersSeekingParty: PlayersSeekingParty; boundary?: Boundary; }; }; arenaDurations: Record<string, number>; } = { gameTime: 0, instances: {}, arenaDurations: {}, }; private initializeInstance = (instance: string, charId: string) => { this.innerState.instances[instance] ||= { time: 0, characters: {}, playersSeekingParty: [], }; this.innerState.instances[instance].characters[charId] ||= { units: {}, gameObjects: {}, }; return this.innerState.instances[instance]; }; public clearState = () => { this.innerState = { gameTime: 0, instances: {}, arenaDurations: {}, }; this.heartBeatInterval && clearInterval(this.heartBeatInterval); }; eventsHandler = (events: BatchedEvents): void => { for (const instanceId in events) { const eventCharMap = events[instanceId]; for (const charId in eventCharMap) { const events = eventCharMap[charId]; this.initializeInstance(instanceId, charId); events.forEach(([eventName, eventPayload]) => { // it's important that this happens before the event is processed so that logging works correctly. this.onEvent?.( instanceId as InstanceName, charId, eventName, eventPayload ); const handler = this.socketEventHandlers[eventName]; if (handler) { // @ts-expect-error handler(instanceId, charId, eventPayload); } }); } } this.runOnTick(); }; public runOnTick = () => { const elapsed = Date.now() - this.lastOnTick; this.lastOnTick = Date.now(); this.innerState.gameTime += elapsed; for (const instanceId in this.innerState.instances) { const instance = this.innerState.instances[instanceId]; if (this.innerState.arenaDurations[instanceId]) { this.innerState.arenaDurations[instanceId] -= elapsed; } for (const charId in instance.characters) { const charState = instance.characters[charId]; for (const unitId in charState.units) { const unit = charState.units[unitId]; for (const effectId in unit.statusEffects) { const effect = unit.statusEffects[effectId as keyof typeof unit.statusEffects]!; effect.duration -= elapsed; } } if (instanceId === "overworld" || instanceId.startsWith("instance-")) { const char = charState.units[charId] as ClientSidePlayer; if (!char) continue; const intent = this.onTick({ inArena: false, player: Object.assign(getHandlers(char), char), boundary: instance.boundary, instanceId, playersSeekingParty: instance.playersSeekingParty, items: localItems, gameObjects: charState.gameObjects, constants: localConstants, recipes: localRecipes, units: charState.units, gameTime: this.innerState.gameTime, }); if (intent) { const updated = { c: charId, i: instanceId as InstanceName, intent: intent, unitId: charId, }; this.setIntent(updated); } } else { // because we run on a timer, we may not have units in our local state // if we're not fighting in the arena, when this is the case, avoid running onTicks if (isEmptyObject(charState.units)) continue; const char = charState.units[charId] as ClientSidePlayer; const intent = this.onTick({ inArena: true, instanceId, arenaTimeRemaining: this.innerState.arenaDurations[instanceId], player: char ? Object.assign(getHandlers(char), char) : undefined, boundary: instance.boundary, items: localItems, gameObjects: charState.gameObjects, constants: localConstants, recipes: localRecipes, playersSeekingParty: instance.playersSeekingParty, units: charState.units, gameTime: this.innerState.gameTime, }); if (intent) { const updated = { c: charId, i: instanceId as InstanceName, intent: intent, unitId: charId, }; this.setIntent(updated); } } } } }; /** * Handles events coming from the server. */ socketEventHandlers: EventMap = { boundary: (instance, charId, event) => { const instanceState = this.initializeInstance(instance, charId); instanceState.boundary = event; }, focus: (instance, charId, event) => { this.updateUnit(instance, charId, event.monsterId, (unit) => { if (!unit) return; if (unit.type !== "monster") return; unit.focus = event.focus; }); }, hazardDamaged: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.hp = event.hp; }); }, used: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { unit.inventory[event.item] ||= 1; unit.inventory[event.item]! -= 1; }); }, combatSkillIncreased: (instance, charId, event) => { this.updatePlayer(instance, charId, event.unitId, (unit) => { unit.combatSkills[event.skill] += event.amount; }); }, dropped: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { unit.inventory[event.item] ||= 0; unit.inventory[event.item]! -= event.amount; }); }, loot: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; if (!("inventory" in unit)) return; entries(event.items).forEach(([item, amount]) => { if (!amount) return; unit.inventory[item] ||= 0; unit.inventory[item]! += amount; }); }); }, arena: (instance, charId, event) => { this.innerState.arenaDurations[instance] = event.duration; }, beganCasting: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.action = "cast"; unit.actionStart = event.gameTime; this.innerState.gameTime = event.gameTime; unit.actionUsing = event.spell; unit.actionDuration = event.duration; unit.actionTarget = event.target; }); }, beganEquippingSpell: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { unit.action = "equipSpell"; unit.actionStart = event.gameTime; unit.actionDuration = event.duration; unit.actionTarget = event.spell; this.innerState.gameTime = event.gameTime; }); }, beganHarvesting: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { unit.action = "harvest"; unit.actionStart = event.gameTime; unit.actionDuration = event.duration; unit.actionTarget = event.objectId; this.innerState.gameTime = event.gameTime; }); }, harvested: (instanceName, charId, event) => { // we don't clear the object, the server should // send a second event telling us the object is gone. this.updateUnit(instanceName, charId, event.unitId, (unit) => { this.clearUnitActions(unit); }); }, castSpell: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.action = undefined; unit.actionStart = undefined; unit.actionDuration = undefined; unit.actionTarget = undefined; }); }, stats: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.stats = event.stats; }); }, lostStatus: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; delete unit.statusEffects[event.effect]; }); }, gainedStatus: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.statusEffects[event.effect] = { duration: event.duration, source: event.source, stacks: event.stacks, effects: event.effects, }; }); }, mp: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.mp = event.mp; }); }, usedWeaponSkill: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; unit.tp = event.tp; }); }, ate: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; if ("calories" in unit) { unit.calories = event.calories; } if ("inventory" in unit) { unit.inventory[event.item] = event.remaining; } }); }, equipped: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; if (isPlayerUnit(unit)) { // @ts-ignore unit.equipment[event.slot] = event.item; } unit.inventory[event.item] ||= 1; unit.inventory[event.item]! -= 1; if (unit.inventory[event.item] === 0) { delete unit.inventory[event.item]; } }); }, unequipped: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { if (!unit) return; if (unit?.type === "player") { const un = unit as ClientSidePlayer; // @ts-ignore unit.equipment[event.slot] = null; un.inventory[event.item] ||= 0; un.inventory[event.item]! += 1; } }); }, beganCrafting: (instance, charId, event) => { this.updateUnit(instance, charId, event.unitId, (unit) => { unit.action = "craft" as const; unit.actionDuration = event.duration; unit.actionStart = event.gameTime; unit.actionTarget = event.item; this.innerState.gameTime = event.gameTime; }); }, finishedCrafting: (instance, charId, event) => { const charState = this.innerState.instances[instance].characters[charId]; this.updateUnit(instance, charId, event.unitId, (unit) => { entries(event.items).forEach(([item, amount]) => { unit.inventory[item] ||= 0; unit.inventory[item]! += amount!; }); entries(event.spent).forEach(([item, amount]) => { unit.inventory[item] ||= amount; unit.inventory[item]! -= amount!; }); this.clearUnitActions(unit); }); }, traded: (instance, charId, event) => { this.updateUnit(instance, charId, event.actingUnitId, (unit): void => { entries(event.gave).forEach(([item, amount]) => { unit.inventory[item] ||= 0; unit.inventory[item]! -= amount!; }); entries(event.got).forEach(([item, amount]) => { unit.inventory[item] ||= 0; unit.inventory[item]! += amount!; delete unit.trades.wants[item]; }); }); this.updateUnit(instance, charId, event.targetUnitId, (unit): void => { entries(event.gave).forEach(([item, amount]) => { unit.inventory[item] ||= 0; unit.inventory[item]! += amount!; delete unit.trades.wants[item]; }); entries(event.got).forEach(([item, amount]) => { unit.inventory[item] ||= 0; unit.inventory[item]! -= amount!; }); }); }, moved: (instance, charId, event) => { this.updateUnit(instance, charId, event.id, (unit) => { if (!unit) return; const { x, y } = event; unit.position = { x, y }; }); }, connectionEvent: (instanceName, charId, event) => { const player = event.player; const instance = this.initializeInstance(instanceName, charId); // This will only come through if our local version doesn't match the server version if (event.items) { localItems = { ...localItems, ...event.items, }; } if (event.constants) { localConstants = { ...localConstants, ...event.constants, }; } localRecipes = { ...localRecipes, ...event.recipes, }; instance.characters[player.id] = { units: event.units, gameObjects: event.gameObjects, }; }, unitAppeared: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); const char = instance.characters[charId]; char.units[event.unit.id] = event.unit; }, unitDisappeared: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); delete instance.characters[charId].units[event.unitId]; }, seekParty: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); let mapped = false; instance.playersSeekingParty = instance.playersSeekingParty.map((val) => { if (val.id === event.playersSeeking.id) { mapped = true; return event.playersSeeking; } return val; }); if (!mapped) { instance.playersSeekingParty.push(event.playersSeeking); } }, acceptedPartyInvite: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); this.updatePlayer(instanceName, charId, event.inviteeId, (player) => { player.partyInvites = []; }); instance.playersSeekingParty = instance.playersSeekingParty.filter( (val) => { return val.id !== event.inviteeId; } ); }, updatedPartyInvites: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, event.unitId, (unit) => { if (!unit) return; if (!isPlayerUnit(unit)) return; unit.partyInvites = event.invites; }); }, updatedParty: (instanceName, charId, event) => { Object.keys(event.party).forEach((memberId) => { this.updateUnit(instanceName, charId, memberId, (unit) => { if (!unit) return; unit.party = event.party; }); }); }, updatedPartyV2: (instanceName, charId, event) => { Object.keys(event.party).forEach((memberId) => { this.updateUnit(instanceName, charId, memberId, (unit) => { if (!unit) return; unit.party = event.party; }); }); this.updateUnit(instanceName, charId, event.playerId, (unit) => { if (!unit) return; unit.party = event.party; }); }, updatedRole: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { unit.role = event.role; }); }, setIntent: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (innerUnit) => { if (innerUnit) { innerUnit.intent = event.intent; // clear out any pending actions if (event.intent === null) { this.clearUnitActions(innerUnit); } } }); }, attacked: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.attacked, (attackedUnit) => { if (!attackedUnit) return; attackedUnit.hp = event.hp; }); this.updateUnit(instanceName, charId, event.attacker, (attackerUnit) => { if (!attackerUnit) return; attackerUnit.tp = event.attackerTp; }); }, despawn: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); delete instance.characters[charId].units[event.unitId]; }, hp: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (!unit) return; unit.hp = event.hp; }); }, tp: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (!unit) return; unit.tp = event.tp; }); }, calories: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (unit && "calories" in unit) { unit.calories = event.calories; } }); }, died: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (!unit) return; unit.hp = 0; }); }, invited: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); const player = instance.characters[charId].units[charId]; if (player && isPlayerUnit(player)) { if ( !player.partyInvites.find((invite) => invite.id === event.inviter.id) ) { player.partyInvites.push({ id: event.inviter.id, name: event.inviter.name, role: event.inviter.role, }); } } }, updatedTrade: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (!unit) return; unit.trades = event.trades; }); }, acceptedQuest: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, event.unitId, (unit) => { unit.quests[event.quest.id] = event.quest; }); if (charId === event.unitId) { this.updateNpc(instanceName, charId, event.quest.start_npc, (npc) => { delete npc.availableQuests[event.quest.id]; }); } }, abandonedQuest: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, event.unitId, (unit) => { delete unit.quests[event.questId]; }); }, completedQuest: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, event.unitId, (unit) => { delete unit.quests[event.questId]; }); }, questAvailable: (instanceName, charId, event) => { this.updateNpc(instanceName, charId, event.npcId, (npc) => { npc.availableQuests[event.quest.id] = event.quest; }); }, questUpdate: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, event.unitId, (unit) => { if (!unit) return; unit.quests[event.quest.id] = event.quest; }); }, inventory: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { if (!unit) return; entries(event.inventory).forEach(([item, amount]) => { if ((amount || 0) <= 0) { delete unit.inventory[item]; } else { unit.inventory[item] = amount; } }); }); }, storageEmptied: (instanceName, charId) => { this.updatePlayer(instanceName, charId, charId, (player) => { player.storage = {}; }); }, storageCharged: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, charId, (player) => { if (event.coinsLeft) { player.storage.copperCoin = event.coinsLeft; } else { delete player.storage.copperCoin; } }); }, withdrew: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, charId, (player) => { entries(event.items).forEach(([itemId, amount = 0]) => { player.storage[itemId] = (player.storage[itemId] || 0) - amount; player.inventory[itemId] = (player.inventory[itemId] || 0) + amount; if (itemId in player.storage && (player.storage[itemId] || 0) <= 0) { delete player.storage[itemId]; } }); }); }, deposited: (instanceName, charId, event) => { this.updatePlayer(instanceName, charId, charId, (player) => { entries(event.items).forEach(([itemId, amount = 0]) => { player.storage[itemId] = (player.storage[itemId] || 0) + amount; player.inventory[itemId] = (player.inventory[itemId] || 0) - amount; if ( itemId in player.inventory && (player.inventory[itemId] || 0) <= 0 ) { delete player.inventory[itemId]; } }); }); }, objectAppeared: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); const char = instance.characters[charId]; char.gameObjects[event.object.id] = event.object; }, objectDisappeared: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); const char = instance.characters[charId]; delete char.gameObjects[event.objectId]; }, objectUpdated: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); const char = instance.characters[charId]; const gameObject = char.gameObjects[event.objectId]; if (gameObject) { // Update the game object with the new properties Object.assign(gameObject, event.properties); } }, unequippedSpell: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { const spell = unit.spellbook[0]; if (!spell) return; unit.spellbook.splice(0, 1); if (unit.type === "player") { unit.inventory[spell] ||= 0; unit.inventory[spell] += 1; } }); }, equippedSpell: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, event.unitId, (unit) => { const { spell } = event; unit.inventory[spell] ||= 1; unit.inventory[spell] -= 1; if (unit.inventory[spell] <= 0) { delete unit.inventory[spell]; } unit.spellbook.push(spell); this.clearUnitActions(unit); }); }, }; private updateUnit = ( instance: string, charId: string, unitId: string, cb: (unit: ClientSideUnit) => void ) => { const char = this.innerState.instances[instance].characters[charId]; if (!char) return; const unit = char.units[unitId]; if (!unit) return; cb(unit); }; /** * Called when the unit completes a cast, or craft, or other action that takes time. */ private clearUnitActions = (unit: ClientSideUnit) => { unit.action = undefined; unit.actionDuration = undefined; unit.actionStart = undefined; unit.actionUsing = undefined; unit.actionTarget = undefined; }; private updateNpc = ( instance: string, charId: string, npcId: string, cb: (npc: ClientSideNPC) => void ) => { this.updateUnit(instance, charId, npcId, (possiblyNpc) => { if (possiblyNpc.type === "npc") { cb(possiblyNpc); } }); }; private updatePlayer = ( instance: string, charId: string, unitId: string, cb: (player: ClientSidePlayer) => void ) => { this.updateUnit(instance, charId, unitId, (unit) => { if (!isPlayerUnit(unit)) return; cb(unit); }); }; }