diff --git a/build/html.ts b/build/html.ts index 4577f6d..b64ee2f 100644 --- a/build/html.ts +++ b/build/html.ts @@ -70,6 +70,10 @@ function connect() { connect(); `; +const POLYFILL = ``; + async function buildBundle(game: string, production: boolean) { const assetsDir = path.resolve(import.meta.dir, 'assets'); const srcDir = path.resolve(import.meta.dir, '..', 'src'); @@ -188,7 +192,7 @@ export async function buildHTML(game: string, { } script = script.replaceAll('await Promise.resolve().then(() =>', '('); - let scriptPrefix = ''; + let scriptPrefix = POLYFILL; if (production) { const minifyResult = UglifyJS.minify(script, { module: true, @@ -201,7 +205,7 @@ export async function buildHTML(game: string, { } } else if (mobile) { const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text(); - scriptPrefix = ``; + scriptPrefix += ``; } if (!local) { scriptPrefix += SW_SCRIPT; diff --git a/src/common/rpg/components/entity.ts b/src/common/rpg/components/entity.ts new file mode 100644 index 0000000..78e102c --- /dev/null +++ b/src/common/rpg/components/entity.ts @@ -0,0 +1,74 @@ +import type { RPGActions, RPGVariables } from "../types"; +import { ACTION_KEYS, VARIABLE_KEYS } from "../decorators"; + +export interface RPGComponent { + getVariables: () => RPGVariables; + getActions: () => RPGActions; +} + +export abstract class RPGComponentBase implements RPGComponent { + getVariables(): RPGVariables { + const meta = (this.constructor as Function)[Symbol.metadata]; + const keys = meta?.[VARIABLE_KEYS] as Map | undefined; + if (!keys) return {}; + const vars: RPGVariables = {}; + for (const [methodKey, exportName] of keys) { + const k = String(methodKey); + const v = (this as unknown as Record)[k]; + if (v != null) { + vars[exportName] = v; + } + } + return vars; + } + + getActions(): RPGActions { + const meta = (this.constructor as Function)[Symbol.metadata]; + const keys = meta?.[ACTION_KEYS] as Set | undefined; + if (!keys) return {}; + const actions: RPGActions = {}; + for (const key of keys) { + const k = String(key); + actions[k] = (arg?: unknown) => (this as unknown as Record unknown>)[k](arg); + } + return actions; + } +} + +export class RPGEntity implements RPGComponent { + private components = new Map(); + + addComponent(id: string, component: RPGComponent): void { + this.components.set(id, component); + } + + removeComponent(id: string): void { + this.components.delete(id); + } + + getComponent(id: string): T | undefined { + return this.components.get(id) as T | undefined; + } + + getVariables(): RPGVariables { + const variables: RPGVariables = {}; + for (const [componentKey, component] of this.components) { + for (const [key, value] of Object.entries(component.getVariables())) { + if (value != null) { + variables[`${componentKey}.${key}`] = value; + } + } + } + return variables; + } + + getActions() { + const actions: RPGActions = {}; + for (const [componentKey, component] of this.components) { + for (const [key, action] of Object.entries(component.getActions())) { + actions[`${componentKey}.${key}`] = action; + } + } + return actions; + } +} \ No newline at end of file diff --git a/src/common/rpg/inventory.ts b/src/common/rpg/components/inventory.ts similarity index 89% rename from src/common/rpg/inventory.ts rename to src/common/rpg/components/inventory.ts index fce9586..9e30421 100644 --- a/src/common/rpg/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -1,16 +1,24 @@ -import type { InventoryOptions, InventorySlotInput, SlotId } from "./types"; +import type { InventoryOptions, InventorySlotInput, SlotId } from "../types"; +import { action } from "../decorators"; +import { RPGComponentBase } from "./entity"; interface SlotEntry { readonly slotId: SlotId; readonly limit: number | undefined; state: { itemId: string; amount: number } | null; } +interface SlotUpdateArgs { + itemId: string; + amount: number; + slotId?: SlotId; +} -export class Inventory { +export class Inventory extends RPGComponentBase { private readonly slots: Map; private readonly maxAmountPerItem: Record; constructor(slotDefs: Array, options?: InventoryOptions) { + super(); this.slots = new Map( slotDefs.map(def => { const slotId = typeof def === 'object' ? def.slotId : def; @@ -34,7 +42,8 @@ export class Inventory { return this.slotCapFor(slot, itemId) - (slot.state?.amount ?? 0); } - addItem(itemId: string, amount: number, slotId?: SlotId): boolean { + @action + addItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean { if (amount < 0) return false; if (amount === 0) return true; @@ -78,7 +87,8 @@ export class Inventory { return remaining === 0; } - removeItem(itemId: string, amount: number, slotId?: SlotId): boolean { + @action + removeItem({ itemId, amount, slotId }: SlotUpdateArgs): boolean { if (amount < 0) return false; if (amount === 0) return true; @@ -129,11 +139,7 @@ export class Inventory { return result; } - getVariables(): Record { - const result: Record = {}; - for (const [itemId, amount] of this.getItems()) { - result[`inventory.${itemId}`] = amount; - } - return result; + override getVariables(): Record { + return Object.fromEntries(this.getItems()); } } diff --git a/src/common/rpg/components/stat.ts b/src/common/rpg/components/stat.ts new file mode 100644 index 0000000..116e01d --- /dev/null +++ b/src/common/rpg/components/stat.ts @@ -0,0 +1,21 @@ +import { action, variable } from "../decorators"; +import { RPGComponentBase } from "./entity"; + +export class Stat extends RPGComponentBase { + @variable private value: number; + @variable private maxValue: number | undefined; + + constructor(value: number, maxValue?: number) { + super(); + this.value = value; + this.maxValue = maxValue; + } + + @action + update(amount: number) { + this.value = Math.max(0, this.value + amount); + if (this.maxValue) { + this.value = Math.min(this.value, this.maxValue); + } + } +} diff --git a/src/common/rpg/components/variables.ts b/src/common/rpg/components/variables.ts new file mode 100644 index 0000000..308f605 --- /dev/null +++ b/src/common/rpg/components/variables.ts @@ -0,0 +1,37 @@ +import type { RPGVariables } from "../types"; +import { action } from "../decorators"; +import { RPGComponentBase } from "./entity"; + +interface Var { + key: string; + value: RPGVariables[string]; +} + +export class Variables extends RPGComponentBase { + private readonly variables: RPGVariables = {}; + + override getVariables() { + return this.variables; + } + + @action + set({ key, value }: Var) { + this.variables[key] = value; + return this.variables; + } + + @action + unset(key: string) { + delete this.variables[key]; + return this.variables; + } + + @action + increment({ key, value }: Var) { + const currentValue = this.variables[key] ?? 0; + if (typeof currentValue === 'number' && typeof value === 'number') { + this.variables[key] = currentValue + value; + } + return this.variables; + } +} diff --git a/src/common/rpg/decorators.ts b/src/common/rpg/decorators.ts new file mode 100644 index 0000000..386c007 --- /dev/null +++ b/src/common/rpg/decorators.ts @@ -0,0 +1,38 @@ +import type { RPGVariables } from "./types"; + +export const ACTION_KEYS = Symbol('rpg.actions'); +export const VARIABLE_KEYS = Symbol('rpg.variables'); + +export function action unknown>( + target: T, + context: ClassMethodDecoratorContext +): T { + const prev = context.metadata[ACTION_KEYS] as Set | undefined; + context.metadata[ACTION_KEYS] = new Set(prev).add(context.name); + return target; +} + +type VariableContext = + | ClassFieldDecoratorContext + | ClassGetterDecoratorContext; + +function registerVariable(context: VariableContext, exportName: string): void { + const prev = context.metadata[VARIABLE_KEYS] as Map | undefined; + context.metadata[VARIABLE_KEYS] = new Map(prev).set(context.name, exportName); +} + +export function variable(name: string): (target: undefined | (() => T), context: VariableContext) => void; +export function variable(target: undefined, context: ClassFieldDecoratorContext): void; +export function variable(target: () => T, context: ClassGetterDecoratorContext): void; +export function variable( + nameOrTarget: string | undefined | (() => RPGVariables[string]), + context?: VariableContext +): unknown { + if (typeof nameOrTarget === 'string') { + const exportName = nameOrTarget; + return (_target: unknown, ctx: VariableContext) => { + registerVariable(ctx, exportName); + }; + } + registerVariable(context!, String(context!.name)); +} diff --git a/src/common/rpg/quest.ts b/src/common/rpg/quest.ts index aecb904..70e5b98 100644 --- a/src/common/rpg/quest.ts +++ b/src/common/rpg/quest.ts @@ -1,4 +1,6 @@ +import { RPGComponentBase } from "./components/entity"; import { evaluateConditions } from "./conditions"; +import { action, variable } from "./decorators"; import { isQuest, type Quest, @@ -10,8 +12,8 @@ import { export type QuestStatus = 'inactive' | 'active' | 'completed'; export interface QuestRuntimeOptions { - variables: RPGVariables; - actions: RPGActions; + getVariables(): RPGVariables; + getActions(): RPGActions; } export namespace Quests { @@ -37,16 +39,15 @@ export namespace Quests { } } -export class QuestEngine { - private _status: QuestStatus = 'inactive'; - private _stageIndex: number = 0; +export class QuestEngine extends RPGComponentBase { + @variable('status') private _status: QuestStatus = 'inactive'; + @variable('stage') private _stageIndex: number = 0; constructor( private readonly quest: Quest, private readonly options: QuestRuntimeOptions, ) { - this.quest = quest; - this.options = options; + super(); } get id(): string { @@ -54,31 +55,29 @@ export class QuestEngine { } isAvailable(): boolean { - return evaluateConditions(this.resolveConditions(this.quest.conditions ?? []), this.options.variables); + return evaluateConditions(this.quest.conditions ?? [], this.options.getVariables()); } + @action start(): void { this._status = 'active'; this._stageIndex = 0; } - private resolveConditions(conditions: string[]): string[] { - const questVar = `quest.${this.quest.id}`; - return conditions.map(c => c.replace(/^(~?)\$/, `$1${questVar}`)); - } - + @action async checkAndAdvance(): Promise { if (this._status !== 'active') return; const stage = this.quest.stages[this._stageIndex]; if (!stage) return; - const allDone = evaluateConditions(this.resolveConditions(stage.objectives.map(obj => obj.condition)), this.options.variables); + const allDone = evaluateConditions(stage.objectives.map(obj => obj.condition), this.options.getVariables()); if (!allDone) return; + const actions = this.options.getActions(); for (const action of stage.actions) { - await this.options.actions[action.type]?.(action.arg); + await actions[action.type]?.(action.arg); } if (this._stageIndex + 1 < this.quest.stages.length) { @@ -88,14 +87,6 @@ export class QuestEngine { } } - getVariables(): RPGVariables { - const prefix = `quest.${this.quest.id}`; - return { - [prefix]: this._status, - [`${prefix}.stage`]: this._stageIndex, - }; - } - get currentStage(): QuestStage | null { return this.quest.stages[this._stageIndex] ?? null; } @@ -105,32 +96,39 @@ export class QuestEngine { } } -export class QuestManager { +export class QuestManager extends RPGComponentBase { private readonly engines: Map; constructor(quests: Quest[], options: QuestRuntimeOptions) { + super(); this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)])); } + @action start(questId: string): void { this.engines.get(questId)?.start(); } + @action async checkAndAdvance(): Promise { for (const engine of this.engines.values()) { await engine.checkAndAdvance(); } } - getVariables(): RPGVariables { - const result: RPGVariables = {}; - for (const engine of this.engines.values()) { - Object.assign(result, engine.getVariables()); - } - return result; - } - getEngine(questId: string): QuestEngine | undefined { return this.engines.get(questId); } + + override getVariables(): RPGVariables { + const result: RPGVariables = {}; + for (const [key, engine] of this.engines) { + for (const [varKey, value] of Object.entries(engine.getVariables())) { + if (value != null) { + result[`${key}.${varKey}`] = value; + } + } + } + return result; + } } diff --git a/src/common/rpg/types.ts b/src/common/rpg/types.ts index 91f0d6a..0dfc3d9 100644 --- a/src/common/rpg/types.ts +++ b/src/common/rpg/types.ts @@ -1,12 +1,12 @@ export type RPGCondition = string; -export type RPGVariables = Record; +export type RPGVariables = Record; export interface RPGAction { type: string; arg?: string | number | boolean | null; } -export type RPGActions = Record Promise | void>; +export type RPGActions = Record unknown>; export interface DialogChoice { text: string; diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 44f39cf..d32904b 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,10 +1,41 @@ -import { DialogEngine, Dialogs } from "@common/rpg/dialog"; -import dialogYml from './dialog.yml'; -import { isDialog } from "@common/rpg/types"; +import { RPGEntity } from "@common/rpg/components/entity"; +import { Inventory } from "@common/rpg/components/inventory"; +import { Stat } from "@common/rpg/components/stat"; +import { Variables } from "@common/rpg/components/variables"; +import { QuestManager } from "@common/rpg/quest"; + export default async function main() { - // console.log(dialogYml); - if (isDialog(dialogYml)) { - console.log(await Dialogs.coverageTest(dialogYml)); - } + const game = new RPGEntity(); + const player = new RPGEntity(); + const inventory = new Inventory(['head', 'legs']); + const quests = new QuestManager([{ + id: 'test', + description: 'Test quest', + title: 'Test', + stages: [], + }], game); + const vars = new Variables(); + + game.addComponent('variables', vars); + game.addComponent('player', player); + game.addComponent('quests', quests); + player.addComponent('inventory', inventory); + player.addComponent('health', new Stat(100)); + console.log(game.getActions()); + + inventory.addItem({ + itemId: 'helmet', + amount: 1, + slotId: 'head', + }); + inventory.addItem({ + itemId: 'boots', + amount: 2, + }); + inventory.addItem({ + itemId: 'belt', + amount: 1, + }); + console.log(game.getVariables()); } \ No newline at end of file diff --git a/src/games/zombies/tilemap.ts b/src/games/zombies/tilemap.ts index 682b7e2..59e67b5 100644 --- a/src/games/zombies/tilemap.ts +++ b/src/games/zombies/tilemap.ts @@ -551,7 +551,7 @@ export default class TileMap extends Entity { } } - public update(dt: number) { + public override update(dt: number) { this.players.forEach((player) => player.update(dt)); this.enemies.forEach((tile) => tile.enemy!.update(dt)); } diff --git a/tsconfig.json b/tsconfig.json index 3309f70..d5b07cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, // Some stricter flags (disabled by default) "noUnusedLocals": false,