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();
</script>`;
const POLYFILL = `<script>
if (!Symbol.metadata) Symbol.metadata = Symbol.for('Symbol.metadata');
</script>`;
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 = `<script>${eruda};\neruda.init();</script>`;
scriptPrefix += `<script>${eruda};\neruda.init();</script>`;
}
if (!local) {
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 {
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<SlotId, SlotEntry>;
private readonly maxAmountPerItem: Record<string, number>;
constructor(slotDefs: Array<InventorySlotInput>, 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<string, number> {
const result: Record<string, number> = {};
for (const [itemId, amount] of this.getItems()) {
result[`inventory.${itemId}`] = amount;
}
return result;
override getVariables(): Record<string, number> {
return Object.fromEntries(this.getItems());
}
}

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 { 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<void> {
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<string, QuestEngine>;
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<void> {
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;
}
}

View File

@ -1,12 +1,12 @@
export type RPGCondition = string;
export type RPGVariables = Record<string, string | number | boolean>;
export type RPGVariables = Record<string, string | number | boolean | undefined>;
export interface RPGAction {
type: string;
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 {
text: string;

View File

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

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

View File

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