1
0
Fork 0

Decorators and components

This commit is contained in:
Pabloader 2026-04-28 11:58:25 +00:00
parent 6b6fd8970d
commit ab68a5ffc6
11 changed files with 264 additions and 54 deletions

View File

@ -70,6 +70,10 @@ function connect() {
connect(); connect();
</script>`; </script>`;
const POLYFILL = `<script>
if (!Symbol.metadata) Symbol.metadata = Symbol.for('Symbol.metadata');
</script>`;
async function buildBundle(game: string, production: boolean) { async function buildBundle(game: string, production: boolean) {
const assetsDir = path.resolve(import.meta.dir, 'assets'); const assetsDir = path.resolve(import.meta.dir, 'assets');
const srcDir = path.resolve(import.meta.dir, '..', 'src'); 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(() =>', '('); script = script.replaceAll('await Promise.resolve().then(() =>', '(');
let scriptPrefix = ''; let scriptPrefix = POLYFILL;
if (production) { if (production) {
const minifyResult = UglifyJS.minify(script, { const minifyResult = UglifyJS.minify(script, {
module: true, module: true,
@ -201,7 +205,7 @@ export async function buildHTML(game: string, {
} }
} else if (mobile) { } else if (mobile) {
const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text(); const eruda = await Bun.file(path.resolve(import.meta.dir, '..', 'node_modules', 'eruda', 'eruda.js')).text();
scriptPrefix = `<script>${eruda};\neruda.init();</script>`; scriptPrefix += `<script>${eruda};\neruda.init();</script>`;
} }
if (!local) { if (!local) {
scriptPrefix += SW_SCRIPT; scriptPrefix += SW_SCRIPT;

View File

@ -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<string | symbol, string> | undefined;
if (!keys) return {};
const vars: RPGVariables = {};
for (const [methodKey, exportName] of keys) {
const k = String(methodKey);
const v = (this as unknown as Record<string, RPGVariables[string]>)[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<string | symbol> | 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<string, (a?: unknown) => unknown>)[k](arg);
}
return actions;
}
}
export class RPGEntity implements RPGComponent {
private components = new Map<string, RPGComponent>();
addComponent(id: string, component: RPGComponent): void {
this.components.set(id, component);
}
removeComponent(id: string): void {
this.components.delete(id);
}
getComponent<T extends RPGComponent>(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;
}
}

View File

@ -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 { interface SlotEntry {
readonly slotId: SlotId; readonly slotId: SlotId;
readonly limit: number | undefined; readonly limit: number | undefined;
state: { itemId: string; amount: number } | null; 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<SlotId, SlotEntry>; private readonly slots: Map<SlotId, SlotEntry>;
private readonly maxAmountPerItem: Record<string, number>; private readonly maxAmountPerItem: Record<string, number>;
constructor(slotDefs: Array<InventorySlotInput>, options?: InventoryOptions) { constructor(slotDefs: Array<InventorySlotInput>, options?: InventoryOptions) {
super();
this.slots = new Map( this.slots = new Map(
slotDefs.map(def => { slotDefs.map(def => {
const slotId = typeof def === 'object' ? def.slotId : 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); 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 false;
if (amount === 0) return true; if (amount === 0) return true;
@ -78,7 +87,8 @@ export class Inventory {
return remaining === 0; 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 false;
if (amount === 0) return true; if (amount === 0) return true;
@ -129,11 +139,7 @@ export class Inventory {
return result; return result;
} }
getVariables(): Record<string, number> { override getVariables(): Record<string, number> {
const result: Record<string, number> = {}; return Object.fromEntries(this.getItems());
for (const [itemId, amount] of this.getItems()) {
result[`inventory.${itemId}`] = amount;
}
return result;
} }
} }

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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<T extends (arg?: any) => unknown>(
target: T,
context: ClassMethodDecoratorContext<unknown, T>
): T {
const prev = context.metadata[ACTION_KEYS] as Set<string | symbol> | undefined;
context.metadata[ACTION_KEYS] = new Set(prev).add(context.name);
return target;
}
type VariableContext<T extends RPGVariables[string]> =
| ClassFieldDecoratorContext<unknown, T>
| ClassGetterDecoratorContext<unknown, T>;
function registerVariable(context: VariableContext<RPGVariables[string]>, exportName: string): void {
const prev = context.metadata[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
context.metadata[VARIABLE_KEYS] = new Map(prev).set(context.name, exportName);
}
export function variable(name: string): <T extends RPGVariables[string]>(target: undefined | (() => T), context: VariableContext<T>) => void;
export function variable<T extends RPGVariables[string]>(target: undefined, context: ClassFieldDecoratorContext<unknown, T>): void;
export function variable<T extends RPGVariables[string]>(target: () => T, context: ClassGetterDecoratorContext<unknown, T>): void;
export function variable(
nameOrTarget: string | undefined | (() => RPGVariables[string]),
context?: VariableContext<RPGVariables[string]>
): unknown {
if (typeof nameOrTarget === 'string') {
const exportName = nameOrTarget;
return (_target: unknown, ctx: VariableContext<RPGVariables[string]>) => {
registerVariable(ctx, exportName);
};
}
registerVariable(context!, String(context!.name));
}

View File

@ -1,4 +1,6 @@
import { RPGComponentBase } from "./components/entity";
import { evaluateConditions } from "./conditions"; import { evaluateConditions } from "./conditions";
import { action, variable } from "./decorators";
import { import {
isQuest, isQuest,
type Quest, type Quest,
@ -10,8 +12,8 @@ import {
export type QuestStatus = 'inactive' | 'active' | 'completed'; export type QuestStatus = 'inactive' | 'active' | 'completed';
export interface QuestRuntimeOptions { export interface QuestRuntimeOptions {
variables: RPGVariables; getVariables(): RPGVariables;
actions: RPGActions; getActions(): RPGActions;
} }
export namespace Quests { export namespace Quests {
@ -37,16 +39,15 @@ export namespace Quests {
} }
} }
export class QuestEngine { export class QuestEngine extends RPGComponentBase {
private _status: QuestStatus = 'inactive'; @variable('status') private _status: QuestStatus = 'inactive';
private _stageIndex: number = 0; @variable('stage') private _stageIndex: number = 0;
constructor( constructor(
private readonly quest: Quest, private readonly quest: Quest,
private readonly options: QuestRuntimeOptions, private readonly options: QuestRuntimeOptions,
) { ) {
this.quest = quest; super();
this.options = options;
} }
get id(): string { get id(): string {
@ -54,31 +55,29 @@ export class QuestEngine {
} }
isAvailable(): boolean { isAvailable(): boolean {
return evaluateConditions(this.resolveConditions(this.quest.conditions ?? []), this.options.variables); return evaluateConditions(this.quest.conditions ?? [], this.options.getVariables());
} }
@action
start(): void { start(): void {
this._status = 'active'; this._status = 'active';
this._stageIndex = 0; this._stageIndex = 0;
} }
private resolveConditions(conditions: string[]): string[] { @action
const questVar = `quest.${this.quest.id}`;
return conditions.map(c => c.replace(/^(~?)\$/, `$1${questVar}`));
}
async checkAndAdvance(): Promise<void> { async checkAndAdvance(): Promise<void> {
if (this._status !== 'active') return; if (this._status !== 'active') return;
const stage = this.quest.stages[this._stageIndex]; const stage = this.quest.stages[this._stageIndex];
if (!stage) return; 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; if (!allDone) return;
const actions = this.options.getActions();
for (const action of stage.actions) { 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) { 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 { get currentStage(): QuestStage | null {
return this.quest.stages[this._stageIndex] ?? 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<string, QuestEngine>; private readonly engines: Map<string, QuestEngine>;
constructor(quests: Quest[], options: QuestRuntimeOptions) { constructor(quests: Quest[], options: QuestRuntimeOptions) {
super();
this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)])); this.engines = new Map(quests.map(q => [q.id, new QuestEngine(q, options)]));
} }
@action
start(questId: string): void { start(questId: string): void {
this.engines.get(questId)?.start(); this.engines.get(questId)?.start();
} }
@action
async checkAndAdvance(): Promise<void> { async checkAndAdvance(): Promise<void> {
for (const engine of this.engines.values()) { for (const engine of this.engines.values()) {
await engine.checkAndAdvance(); 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 { getEngine(questId: string): QuestEngine | undefined {
return this.engines.get(questId); 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;
}
} }

View File

@ -1,12 +1,12 @@
export type RPGCondition = string; export type RPGCondition = string;
export type RPGVariables = Record<string, string | number | boolean>; export type RPGVariables = Record<string, string | number | boolean | undefined>;
export interface RPGAction { export interface RPGAction {
type: string; type: string;
arg?: string | number | boolean | null; arg?: string | number | boolean | null;
} }
export type RPGActions = Record<string, (arg?: unknown) => Promise<void> | void>; export type RPGActions = Record<string, (arg?: any) => unknown>;
export interface DialogChoice { export interface DialogChoice {
text: string; text: string;

View File

@ -1,10 +1,41 @@
import { DialogEngine, Dialogs } from "@common/rpg/dialog"; import { RPGEntity } from "@common/rpg/components/entity";
import dialogYml from './dialog.yml'; import { Inventory } from "@common/rpg/components/inventory";
import { isDialog } from "@common/rpg/types"; 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() { export default async function main() {
// console.log(dialogYml); const game = new RPGEntity();
if (isDialog(dialogYml)) { const player = new RPGEntity();
console.log(await Dialogs.coverageTest(dialogYml)); 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());
} }

View File

@ -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.players.forEach((player) => player.update(dt));
this.enemies.forEach((tile) => tile.enemy!.update(dt)); this.enemies.forEach((tile) => tile.enemy!.update(dt));
} }

View File

@ -19,6 +19,7 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,