UNPKG

programming-game

Version:

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

896 lines (876 loc) 28.3 kB
import { OnTick } from "."; import { Equipments, Items, items, UniqueItemDefinition, UsableItems, } from "./items"; import { recipes } from "./recipes"; import { Spells, CastIntent } from "./spells"; import { AbandonQuestIntent, AcceptPartyInviteIntent, AcceptQuestIntent, AttackIntent, BatchedEvents, Boundary, BuyItemsIntent, ClientSideNPC, ClientSidePlayer, ClientSideUnit, CraftIntent, DepositIntent, DropIntent, EatIntent, EquipIntent, EventMap, GameObject, InstanceName, Intent, IntentType, Inventory, InviteToPartyIntent, LeavePartyIntent, MoveIntent, NewTrades, PlayerEquipment, PlayersSeekingParty, RawEvents, RespawnIntent, ROLES, SeekPartyIntent, SellIntent, SellItemsIntent, SetRoleIntent, SetSpellStonesIntent, SetTradeIntent, SummonManaIntent, Trades, TurnInQuestIntent, UnequipIntent, UniqueItemId, UseIntent, WeaponSkillIntent, WithdrawIntent, } from "./types"; import { WeaponSkill, WeaponSkillSpecifics } from "./weapon-skills"; export type OnTickCurrentPlayer = ClientSidePlayer & ReturnType<typeof getHandlers>; const isPlayerUnit = (unit?: ClientSideUnit): unit is ClientSidePlayer => { return !!unit && unit.type === "player"; }; const entries = <T extends Record<string, unknown>>( obj: T ): [keyof T, T[keyof T]][] => { return Object.entries(obj) as [keyof T, T[keyof T]][]; }; const keys = <T extends Record<string, unknown>>(obj: T): (keyof T)[] => { return Object.keys(obj); }; const getHandlers = (player: ClientSidePlayer) => { return { setRole: (role: ROLES): SetRoleIntent => { return { type: IntentType.setRole, role }; }, attack: (target: ClientSideUnit): AttackIntent => { return { type: IntentType.attack, target: target.id }; }, move: (position: { x: number; y: number }): MoveIntent => { return { type: IntentType.move, position }; }, respawn: (): RespawnIntent => { return { type: IntentType.respawn } as RespawnIntent; }, summonMana: (): SummonManaIntent => { return { type: IntentType.summonMana }; }, eat: (item: Items): EatIntent => { return { type: IntentType.eat, item: item, save: (player.inventory[item] || 0) - 1, }; }, cast: (spell: Spells, target?: ClientSideUnit): CastIntent => { return { type: IntentType.cast, spell, target: target?.id }; }, sell: (opt: { items: { [key in Items]?: number }; to: ClientSideUnit; }): SellItemsIntent => { return { type: IntentType.sellItems, items: entries(opt.items).reduce((acc, [item, amount]) => { const until = (player.inventory[item] || 0) - (amount || 0); acc[item] = until; return acc; }, {} as { [key in Items]: number }), to: opt.to.id, }; }, buy: (opts: { items: { [key in Items]?: number }; from: ClientSideUnit; }): BuyItemsIntent => { return { type: IntentType.buyItems, items: entries(opts.items).reduce((acc, [item, amount]) => { if (!amount) return acc; const until = amount + (player.inventory[item] || 0); if (amount) { acc[item] = until; } return acc; }, {} as { [key in Items]: number }), from: opts.from.id, }; }, use: (item: UsableItems, target?: ClientSideUnit): UseIntent => { return { type: IntentType.use, item, until: (player.inventory[item] || 0) - 1, target: target?.id, }; }, seekParty: (): SeekPartyIntent => { return { type: IntentType.seekParty }; }, inviteToParty: (playerId: string): InviteToPartyIntent => { return { type: IntentType.inviteToParty, playerId }; }, leaveParty: (): LeavePartyIntent => { return { type: IntentType.leaveParty }; }, acceptPartyInvite: (playerId: string): AcceptPartyInviteIntent => { return { type: IntentType.acceptPartyInvite, playerId }; }, equip: (item: Equipments, slot: keyof PlayerEquipment): EquipIntent => { return { type: IntentType.equip, item, slot }; }, unequip: (slot: keyof PlayerEquipment): UnequipIntent => { return { type: IntentType.unequip, slot }; }, craft: ( item: keyof typeof recipes, from: Partial<Record<Items, number>> ): CraftIntent => { return { type: IntentType.craft, item, from }; }, useWeaponSkill: <T extends WeaponSkill>( specifics: WeaponSkillSpecifics[T]["client"] ): WeaponSkillIntent<T> => { if (specifics.skill === "misdirectingShot") { return { type: IntentType.weaponSkill, skill: specifics.skill, target: specifics.target.id, options: { to: specifics.target.id, }, }; } return { type: IntentType.weaponSkill, skill: specifics.skill, target: specifics.target.id, }; }, drop: ({ item, amount }: { item: Items; amount: number }): DropIntent => { return { type: IntentType.drop, item, until: (player.inventory[item] || 0) - amount, }; }, setSpellStones: ( item: Items, stones: Spells[], name: string ): SetSpellStonesIntent => { return { type: IntentType.setSpellStones, item: item, stones, name, }; }, setTrade: (trades: NewTrades): SetTradeIntent => { return { type: IntentType.setTrade, trades, }; }, acceptQuest: (npc: ClientSideNPC, questId: string): AcceptQuestIntent => { return { type: IntentType.acceptQuest, npcId: npc.id, questId }; }, abandonQuest: (questId: string): AbandonQuestIntent => { return { type: IntentType.abandonQuest, questId }; }, turnInQuest: (npc: ClientSideNPC, questId: string): TurnInQuestIntent => { return { type: IntentType.turnInQuest, npcId: npc.id, questId }; }, withdraw: (npc: ClientSideNPC, items: Inventory): WithdrawIntent => { const until: Inventory = {}; entries(items).forEach(([item, amount = 0]) => { const charAmount = player.storage[item] || 0; until[item] = charAmount - amount; }); return { type: IntentType.withdraw, npcId: npc.id, until, }; }, deposit: (npc: ClientSideNPC, items: Inventory): DepositIntent => { const until: Inventory = {}; entries(items).forEach(([item, amount = 0]) => { const charAmount = player.inventory[item] || 0; until[item] = charAmount - amount; }); return { type: IntentType.deposit, npcId: npc.id, until, }; }, }; }; 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, }: { onTick: OnTick; onEvent?: OnEvent; setIntent: SetIntent; }) { this.onTick = onTick; this.onEvent = onEvent; this.setIntent = setIntent; this.heartBeatInterval = setInterval(this.runOnTick, 300); } private time = 0; private lastOnTick = Date.now(); private innerState: { instances: { [key: string]: { time: number; characters: Record< string, { units: Record<string, ClientSideUnit>; uniqueItems: Record<UniqueItemId, UniqueItemDefinition>; gameObjects: Record<string, GameObject>; } >; playersSeekingParty: Map<string, PlayersSeekingParty[0]>; boundary?: Boundary; }; }; arenaDurations: Record<string, number>; } = { instances: {}, arenaDurations: {}, }; private initializeInstance = (instance: string, charId: string) => { this.innerState.instances[instance] ||= { time: 0, characters: {}, playersSeekingParty: new Map(), }; this.innerState.instances[instance].characters[charId] ||= { units: {}, uniqueItems: {}, gameObjects: {}, }; return this.innerState.instances[instance]; }; public clearState = () => { this.innerState = { instances: {}, arenaDurations: {}, }; this.heartBeatInterval && clearInterval(this.heartBeatInterval); }; eventsHandler = (events: BatchedEvents): void => { Object.keys(events).forEach((instanceId) => { const eventCharMap = events[instanceId]; Object.entries(eventCharMap).forEach(([charId, events]) => { this.initializeInstance(instanceId, charId); events.forEach(([eventName, eventPayload]) => { 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(); Object.entries(this.innerState.instances).forEach( ([instanceId, instance]) => { if (this.innerState.arenaDurations[instanceId]) { this.innerState.arenaDurations[instanceId] -= elapsed; } Object.keys(instance.characters).map((charId) => { const charState = instance.characters[charId]; Object.values(charState.units).forEach((unit) => { Object.values(unit.statusEffects).forEach((effect) => { effect.duration -= elapsed; }); }); this.time++; if ( instanceId === "overworld" || instanceId.startsWith("instance-") ) { const char = charState.units[charId] as ClientSidePlayer; if (!char) return; const intent = this.onTick({ inArena: false, player: { ...char, ...getHandlers(char), }, boundary: instance.boundary, instanceId, playersSeekingParty: Array.from( instance.playersSeekingParty.values() ), items: { ...charState.uniqueItems, ...items, }, gameObjects: { ...charState.gameObjects, }, time: this.time, units: charState.units, }); 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 onTics if (!Object.keys(charState.units).length) return; const char = charState.units[charId] as ClientSidePlayer; const intent = this.onTick({ inArena: true, instanceId, arenaTimeRemaining: this.innerState.arenaDurations[instanceId], player: char ? { ...char, ...getHandlers(char), } : undefined, boundary: instance.boundary, items: { ...charState.uniqueItems, ...items, }, gameObjects: { ...charState.gameObjects, }, playersSeekingParty: Array.from( instance.playersSeekingParty.values() ), time: this.time, units: charState.units, }); 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; }); }, 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 = Date.now(); unit.actionDuration = event.duration; unit.actionTarget = event.target; }); }, 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; }); }, 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 = Date.now(); unit.actionTarget = event.item; }); }, 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!; }); if (event.uniqueItems) { entries(event.uniqueItems).forEach(([item, def]) => { if (!item || !def) return; charState.uniqueItems[item] = def; }); } unit.action = undefined; unit.actionDuration = undefined; unit.actionStart = undefined; unit.actionUsing = undefined; unit.actionTarget = undefined; }); }, 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); instance.characters[player.id] = { units: event.units, uniqueItems: event.uniqueItems, 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; keys(event.uniqueItems).forEach((uniqueItemId) => { char.uniqueItems[uniqueItemId] = event.uniqueItems[uniqueItemId]; }); }, 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); instance.playersSeekingParty.set( event.playersSeeking.id, event.playersSeeking ); }, acceptedPartyInvite: (instanceName, charId, event) => { const instance = this.initializeInstance(instanceName, charId); instance.playersSeekingParty.delete(event.inviteeId); }, updatedParty: (instanceName, charId, event) => { this.updateUnit(instanceName, charId, charId, (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; } }); }, 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)) { 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); } }, }; 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); }; 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); }); }; }