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,