Compare commits
3 Commits
2bedecb677
...
5c46a531fa
| Author | SHA1 | Date |
|---|---|---|
|
|
5c46a531fa | |
|
|
2ac6e37666 | |
|
|
a571d6cc2a |
|
|
@ -138,6 +138,11 @@ export enum Glyphs {
|
|||
DIAMOND = '♦',
|
||||
}
|
||||
|
||||
export enum AlignHorizontal {
|
||||
LEFT = 'left',
|
||||
RIGHT = 'right',
|
||||
}
|
||||
|
||||
export const isVertical = (char: string) => char === Glyphs.SINGLE_VERTICAL || char === Glyphs.DOUBLE_VERTICAL;
|
||||
export const isHorizontal = (char: string) => char === Glyphs.SINGLE_HORIZONTAL || char === Glyphs.DOUBLE_HORIZONTAL;
|
||||
export const isCorner = (char: string) => [
|
||||
|
|
@ -145,7 +150,14 @@ export const isCorner = (char: string) => [
|
|||
Glyphs.DOUBLE_TOP_LEFT, Glyphs.DOUBLE_TOP_RIGHT, Glyphs.DOUBLE_BOTTOM_LEFT, Glyphs.DOUBLE_BOTTOM_RIGHT,
|
||||
].includes(char as Glyphs);
|
||||
|
||||
interface BoxOptions {
|
||||
interface StringOptions {
|
||||
fg?: ColorLike;
|
||||
bg?: ColorLike;
|
||||
alignH?: AlignHorizontal;
|
||||
anchorH?: AlignHorizontal;
|
||||
}
|
||||
|
||||
interface BoxOptions extends StringOptions {
|
||||
vertical?: string;
|
||||
horizontal?: string;
|
||||
topLeft?: string;
|
||||
|
|
@ -153,8 +165,6 @@ interface BoxOptions {
|
|||
bottomLeft?: string;
|
||||
bottomRight?: string;
|
||||
fill?: Char;
|
||||
fg?: ColorLike;
|
||||
bg?: ColorLike;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
|
|
@ -313,9 +323,14 @@ export class TextDisplay {
|
|||
return [this.chars[y * this.width + x], this.fgs[y * this.width + x], this.bgs[y * this.width + x]];
|
||||
}
|
||||
|
||||
setRegion(x: number, y: number, region: TextRegion) {
|
||||
setRegion(region: TextRegion, x: number, y: number, anchorH = AlignHorizontal.LEFT) {
|
||||
x = x | 0;
|
||||
y = y | 0;
|
||||
|
||||
if (anchorH === AlignHorizontal.RIGHT) {
|
||||
x -= region.width + 1;
|
||||
}
|
||||
|
||||
const { chars, fgs, bgs } = region[REGION_DATA];
|
||||
const rw = region.width;
|
||||
const x0 = Math.max(this.clipLeft, x);
|
||||
|
|
@ -375,15 +390,31 @@ export class TextDisplay {
|
|||
return new TextRegion(data);
|
||||
}
|
||||
|
||||
drawString(text: unknown, x: number, y: number, fg: ColorLike = DEFAULT_FG, bg: ColorLike = DEFAULT_BG) {
|
||||
drawString(
|
||||
text: unknown, x: number, y: number,
|
||||
options: StringOptions,
|
||||
) {
|
||||
x = x | 0; y = y | 0;
|
||||
const {
|
||||
fg = DEFAULT_FG,
|
||||
bg = DEFAULT_BG,
|
||||
alignH = AlignHorizontal.LEFT,
|
||||
anchorH = AlignHorizontal.LEFT,
|
||||
} = options;
|
||||
const lines = String(text).split('\n');
|
||||
const maxWidth = lines.reduce((m, line) => Math.max(m, line.length), 0);
|
||||
|
||||
if (anchorH === AlignHorizontal.RIGHT) {
|
||||
x -= maxWidth - 1;
|
||||
}
|
||||
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
const line = lines[row];
|
||||
let line = lines[row];
|
||||
const ry = y + row;
|
||||
if (ry < this.clipTop || ry >= this.clipBottom) continue;
|
||||
const offset = alignH === AlignHorizontal.RIGHT ? maxWidth - line.length : 0;
|
||||
for (let col = 0; col < line.length; col++) {
|
||||
this.setCharRaw(x + col, ry, line[col], fg, bg);
|
||||
this.setCharRaw(x + col + offset, ry, line[col], fg, bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -428,8 +459,13 @@ export class TextDisplay {
|
|||
bg = DEFAULT_BG,
|
||||
fill,
|
||||
title,
|
||||
anchorH = AlignHorizontal.LEFT,
|
||||
} = options;
|
||||
|
||||
if (anchorH === AlignHorizontal.RIGHT) {
|
||||
x -= width + 1;
|
||||
}
|
||||
|
||||
this.setCharRaw(x, y, topLeft, fg, bg);
|
||||
this.setCharRaw(x + width + 1, y, topRight, fg, bg);
|
||||
this.setCharRaw(x, y + height + 1, bottomLeft, fg, bg);
|
||||
|
|
@ -446,7 +482,7 @@ export class TextDisplay {
|
|||
this.drawVLine(x + width + 1, y + 1, y + height, [vertical, fg, bg]);
|
||||
|
||||
if (title) {
|
||||
this.drawString(title, x + 1, y, fg, bg);
|
||||
this.drawString(title, x + 1, y, { fg, bg });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,13 +491,26 @@ export class TextDisplay {
|
|||
const {
|
||||
fg = DEFAULT_FG,
|
||||
bg = DEFAULT_BG,
|
||||
alignH = AlignHorizontal.LEFT,
|
||||
anchorH = AlignHorizontal.LEFT,
|
||||
title,
|
||||
} = options;
|
||||
const lines = String(text).split('\n');
|
||||
const width = lines.reduce((m, line) => Math.max(m, line.length), 0);
|
||||
const textWidth = lines.reduce((m, line) => Math.max(m, line.length), 0);
|
||||
const height = lines.length;
|
||||
|
||||
let width = textWidth;
|
||||
if (title) {
|
||||
width = Math.max(width, title.length);
|
||||
}
|
||||
|
||||
let dx = anchorH === AlignHorizontal.RIGHT ? -1 : 1;
|
||||
if (alignH === AlignHorizontal.LEFT && anchorH === AlignHorizontal.RIGHT) {
|
||||
dx -= width - textWidth;
|
||||
}
|
||||
|
||||
this.drawBox(x, y, width, height, { ...options, fill: ' ' });
|
||||
this.drawString(text, x + 1, y + 1, fg, bg);
|
||||
this.drawString(text, x + dx, y + 1, { fg, bg, alignH, anchorH });
|
||||
}
|
||||
|
||||
fillBox(x: number, y: number, width: number, height: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { component } from "../utils/decorators";
|
|||
import { Stat } from "./stat";
|
||||
|
||||
@component
|
||||
export class Defense extends Stat<{ damageType: string }> { }
|
||||
export class Defense extends Stat<{ damageType?: string }> { }
|
||||
|
||||
@component
|
||||
export class Damage extends Stat<{ damageType: string, minDamage?: number, variance?: number }> { }
|
||||
export class Damage extends Stat<{ damageType?: string, minDamage?: number, variance?: number }> {
|
||||
get variance(): number {
|
||||
return this.state.variance ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@component
|
||||
export class Crit extends Stat<{ chance: number }> { }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component } from "../core/world";
|
||||
import { Component, Entity } from "../core/world";
|
||||
import type { RPGVariables } from "../types";
|
||||
import { action, component, ComponentTag, getComponentTags } from "../utils/decorators";
|
||||
import { Effect } from "./effect";
|
||||
|
|
@ -60,10 +60,17 @@ export class Equipment extends Component<EquipmentState> {
|
|||
}
|
||||
|
||||
/** ItemId in the named slot, or null if empty. */
|
||||
getItem(slotName: string): string | null {
|
||||
getItemId(slotName: string): string | null {
|
||||
return this.#slot(slotName)?.itemId ?? null;
|
||||
}
|
||||
|
||||
getItem(slotName: string): Entity | undefined {
|
||||
const id = this.getItemId(slotName);
|
||||
if (!id) return undefined;
|
||||
|
||||
return this.entity.world.getEntity(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first empty slot compatible with `slotType`.
|
||||
* Typed slots that match take priority over generic (untyped) slots.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ function xpForStep(spec: ThresholdSpec, level: number): number | null {
|
|||
return Math.floor(spec.base * Math.pow(spec.factor, level - 1));
|
||||
}
|
||||
|
||||
export interface LevelUpEvent {
|
||||
level: number;
|
||||
prev: number;
|
||||
}
|
||||
|
||||
@component
|
||||
export class Experience extends Component<{
|
||||
xp: number;
|
||||
|
|
@ -39,9 +44,13 @@ export class Experience extends Component<{
|
|||
/** XP accumulated within the current level. */
|
||||
@variable get xpInLevel(): number { return this.state.xp - this.state.xpAtLevel; }
|
||||
|
||||
get xpForNext(): number | null {
|
||||
return xpForStep(this.state.spec, this.state.level);
|
||||
}
|
||||
|
||||
/** XP remaining until the next level, or `null` at max level. */
|
||||
get xpToNext(): number | null {
|
||||
const needed = xpForStep(this.state.spec, this.state.level);
|
||||
const needed = this.xpForNext;
|
||||
return needed === null ? null : needed - this.xpInLevel;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,11 +193,11 @@ export class Inventory extends Component<InventoryState> {
|
|||
* `slotName` specifies the equipment body slot (otherwise auto-detected by item type).
|
||||
*/
|
||||
@action
|
||||
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string }): boolean {
|
||||
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string } | Entity): boolean {
|
||||
const resolved = this.#resolveItem(arg);
|
||||
if (!resolved) return false;
|
||||
const { itemId, slotId } = resolved;
|
||||
const slotName = typeof arg === 'object' ? arg.slotName : undefined;
|
||||
const slotName = typeof arg === 'object' && 'slotName' in arg ? arg.slotName : undefined;
|
||||
|
||||
if (this.getAmount(itemId, slotId) === 0) {
|
||||
console.warn(`[Inventory] equip: item '${itemId}' not in inventory`);
|
||||
|
|
@ -262,9 +262,10 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
|
||||
/** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */
|
||||
#resolveItem(arg?: string | { itemId?: string; slotId?: SlotId }): { itemId: string; slotId?: SlotId } | null {
|
||||
#resolveItem(arg?: string | { itemId?: string; slotId?: SlotId } | Entity): { itemId: string; slotId?: SlotId } | null {
|
||||
if (!arg) return null;
|
||||
if (typeof arg === 'string') return { itemId: arg };
|
||||
if (arg instanceof Entity) return { itemId: arg.id };
|
||||
|
||||
if (arg.slotId !== undefined) {
|
||||
const contents = this.getSlotContents(arg.slotId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { Component } from "../core/world";
|
||||
import { component } from "../utils/decorators";
|
||||
|
||||
@component
|
||||
export class Name extends Component<string> { }
|
||||
|
|
@ -1,19 +1,49 @@
|
|||
import { type RNGState, SeededRandom } from "@common/random";
|
||||
import { type NameOptions, type RNGState, SeededRandom } from "@common/random";
|
||||
import { Component, World } from "../core/world";
|
||||
import { component } from "../utils/decorators";
|
||||
|
||||
@component
|
||||
export class Random extends Component<{ random: RNGState }> {
|
||||
export class Random extends Component<{ random: RNGState }> implements Omit<SeededRandom, 'jump' | 'longJump' | 'fork' | 'toJSON' | 'getState' | 'setState'> {
|
||||
private rng?: SeededRandom;
|
||||
|
||||
constructor(random: SeededRandom | string | number | RNGState = Date.now()) {
|
||||
super({
|
||||
random: (() => {
|
||||
this.rng = random instanceof SeededRandom ? random : new SeededRandom(random);
|
||||
return this.rng.getState();
|
||||
const rng = random instanceof SeededRandom ? random : new SeededRandom(random);
|
||||
return rng.getState();
|
||||
})()
|
||||
});
|
||||
}
|
||||
nextFloat(): number {
|
||||
return this.use((rng) => rng.nextFloat());
|
||||
}
|
||||
nextInt(min: number, max: number): number;
|
||||
nextInt(max: number): number;
|
||||
nextInt(minOrMax: number, max?: number): number {
|
||||
return this.use((rng) => rng.nextInt(minOrMax, max as number));
|
||||
}
|
||||
nextBool(): boolean {
|
||||
return this.use((rng) => rng.nextBool());
|
||||
}
|
||||
nextName(options?: NameOptions): string {
|
||||
return this.use((rng) => rng.nextName(options));
|
||||
}
|
||||
choice<T>(iterable: Iterable<T>): T;
|
||||
choice<T>(iterable: Iterable<T>, k: number): T[];
|
||||
choice<T>(iterable: Iterable<T>, k?: number): T | T[] {
|
||||
return this.use((rng) => rng.choice(iterable, k as number));
|
||||
}
|
||||
weightedChoice<T>(items: readonly T[], weights: readonly number[]): T;
|
||||
weightedChoice<T>(items: readonly T[], weights: readonly number[], k: number): T[];
|
||||
weightedChoice<T>(items: readonly T[], weights: readonly number[], k?: number): T | T[] {
|
||||
return this.use((rng) => rng.weightedChoice(items, weights, k as number));
|
||||
}
|
||||
shuffle<T>(arr: T[]): T[] {
|
||||
return this.use((rng) => rng.shuffle(arr));
|
||||
}
|
||||
toShuffled<T>(arr: readonly T[]): T[] {
|
||||
return this.use((rng) => rng.toShuffled(arr));
|
||||
}
|
||||
|
||||
use<T>(fn: (rng: SeededRandom) => T): T {
|
||||
if (this.rng) {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import { ACTION_KEYS, getComponentName, STATE_KEYS, VARIABLE_KEYS } from '../uti
|
|||
|
||||
interface EntityEvent<T = unknown> {
|
||||
target: Entity;
|
||||
data?: T;
|
||||
data: T;
|
||||
}
|
||||
interface WorldEvent<T = unknown> {
|
||||
target: World;
|
||||
data?: T;
|
||||
target: World | Entity;
|
||||
data: T;
|
||||
}
|
||||
|
||||
type EntityEventHandler = <T>(event: EntityEvent<T>) => void;
|
||||
type WorldEventHandler = <T>(event: WorldEvent<T>) => void;
|
||||
type EntityEventHandler<T = unknown> = (event: EntityEvent<T>) => void;
|
||||
type WorldEventHandler<T = unknown> = (event: WorldEvent<T>) => void;
|
||||
|
||||
export type EvalContext = Entity | World;
|
||||
|
||||
|
|
@ -105,6 +105,7 @@ type ComponentFilter<T> = (component: T) => boolean;
|
|||
|
||||
export class Entity {
|
||||
readonly #components = new Map<string | symbol, Component>();
|
||||
#destroyed = false;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
|
|
@ -112,6 +113,7 @@ export class Entity {
|
|||
) { }
|
||||
|
||||
add<T extends Component<any>>(component: T, k?: string): T {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
const key = k ?? Symbol();
|
||||
|
||||
if (component == null) {
|
||||
|
|
@ -127,6 +129,7 @@ export class Entity {
|
|||
}
|
||||
|
||||
clone<T extends Component<any>>(component: T, key: string): T {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
const clone = Object.create(component.constructor.prototype) as T;
|
||||
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
||||
return this.add(clone, key);
|
||||
|
|
@ -136,6 +139,7 @@ export class Entity {
|
|||
get<T extends Component<any>>(ctor: Class<T>, key?: string): T | undefined;
|
||||
get<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): T | undefined;
|
||||
get<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): T | undefined {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
return this.#components.get(ctorOrKey) as T | undefined;
|
||||
}
|
||||
|
|
@ -162,6 +166,7 @@ export class Entity {
|
|||
}
|
||||
|
||||
getAll<T extends Component<any>>(ctor: Class<T>): T[] {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
const result: T[] = [];
|
||||
for (const c of this.#components.values()) {
|
||||
if (c instanceof ctor) result.push(c as T);
|
||||
|
|
@ -174,6 +179,7 @@ export class Entity {
|
|||
has<T extends Component<any>>(ctor: Class<T>, key: string): boolean;
|
||||
has<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): boolean;
|
||||
has<T extends Component<any>>(ctorOrKey: Class<T> | string, key?: string | ComponentFilter<T>): boolean {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
return this.#components.has(ctorOrKey);
|
||||
}
|
||||
|
|
@ -195,6 +201,7 @@ export class Entity {
|
|||
remove<T extends Component<any>>(ctor: Class<T>, key: string): void;
|
||||
remove<T extends Component<any>>(ctor: Class<T>, filter: ComponentFilter<T>): void;
|
||||
remove<T extends Component<any>>(ctorOrKey: Class<T> | T | string, key?: string | ComponentFilter<T>): void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
if (typeof ctorOrKey === 'string') {
|
||||
this.#removeByKey(ctorOrKey);
|
||||
return;
|
||||
|
|
@ -216,6 +223,7 @@ export class Entity {
|
|||
}
|
||||
|
||||
removeAll<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
for (const [k, c] of this.#components) {
|
||||
if (!(c instanceof ctor)) continue;
|
||||
if (typeof filter === 'function' && !filter(c)) continue;
|
||||
|
|
@ -230,22 +238,27 @@ export class Entity {
|
|||
}
|
||||
|
||||
emit(event: string, data?: unknown): void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
this.world.emit(this.id, event, data);
|
||||
}
|
||||
|
||||
emitGlobal(event: string, data?: unknown): void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
this.world.emitGlobal(event, data);
|
||||
}
|
||||
|
||||
on(event: string, handler: EntityEventHandler): () => void {
|
||||
on<T>(event: string, handler: EntityEventHandler<T>): () => void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
return this.world.on(this.id, event, handler);
|
||||
}
|
||||
|
||||
off(event: string, handler: EntityEventHandler): void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
this.world.off(this.id, event, handler);
|
||||
}
|
||||
|
||||
once(event: string, handler: EntityEventHandler): () => void {
|
||||
once<T>(event: string, handler: EntityEventHandler<T>): () => void {
|
||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||
return this.world.once(this.id, event, handler);
|
||||
}
|
||||
|
||||
|
|
@ -253,6 +266,8 @@ export class Entity {
|
|||
this.world.destroyEntity(this);
|
||||
}
|
||||
|
||||
get destroyed() { return this.#destroyed; }
|
||||
|
||||
/** @internal */
|
||||
[Symbol.iterator](): IterableIterator<[string, Component]> {
|
||||
return this.#components.values().map(c => [c.key, c]);
|
||||
|
|
@ -262,6 +277,7 @@ export class Entity {
|
|||
_destroy(): void {
|
||||
for (const c of this.#components.values()) c.onRemove();
|
||||
this.#components.clear();
|
||||
this.#destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,18 +398,21 @@ export class World {
|
|||
}
|
||||
|
||||
emit(entityId: string, event: string, data?: unknown): void {
|
||||
console.debug(`Emitting event ${event} for entity ${entityId}, data:`, data);
|
||||
const entity = this.getEntity(entityId);
|
||||
if (!entity) return;
|
||||
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data }));
|
||||
this.#globalHandlers.get(event)?.forEach(h => h({ target: entity, data }));
|
||||
}
|
||||
|
||||
emitGlobal(event: string, data?: unknown): void {
|
||||
console.debug(`Emitting global event ${event}, data:`, data);
|
||||
this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data }));
|
||||
}
|
||||
|
||||
on(event: string, handler: WorldEventHandler): () => void;
|
||||
on(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||
on(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||
on<T>(event: string, handler: WorldEventHandler<T>): () => void;
|
||||
on<T>(entityId: string, event: string, handler: EntityEventHandler<T>): () => void;
|
||||
on<T>(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler<T>): () => void {
|
||||
if (typeof arg2 === 'string') {
|
||||
return this.#addHandler(this.#handlers, `${arg1}\0${arg2}`, arg3!);
|
||||
}
|
||||
|
|
@ -414,12 +433,12 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
once(event: string, handler: WorldEventHandler): () => void;
|
||||
once(entityId: string, event: string, handler: EntityEventHandler): () => void;
|
||||
once(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): () => void {
|
||||
once<T>(event: string, handler: WorldEventHandler<T>): () => void;
|
||||
once<T>(entityId: string, event: string, handler: EntityEventHandler<T>): () => void;
|
||||
once<T>(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler<T>): () => void {
|
||||
if (typeof arg2 === 'string') {
|
||||
const original = arg3!;
|
||||
const wrapped: EntityEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||
const wrapped: EntityEventHandler<T> = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
||||
this.#onceWrappers.set(original, wrapped);
|
||||
const unsub = this.on(arg1, arg2, wrapped);
|
||||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||
|
|
@ -431,7 +450,7 @@ export class World {
|
|||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
||||
}
|
||||
|
||||
#addHandler<T extends WorldEventHandler | EntityEventHandler>(
|
||||
#addHandler<T extends WorldEventHandler<any> | EntityEventHandler<any>>(
|
||||
map: Map<string, Set<T>>,
|
||||
key: string,
|
||||
handler: T,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import { System, World } from "../core/world";
|
|||
|
||||
let hitEffectCounter = 0;
|
||||
|
||||
export interface HitEvent {
|
||||
attackerId: string;
|
||||
sourceId: string | null;
|
||||
amount: number;
|
||||
damageType?: string;
|
||||
}
|
||||
|
||||
export class CombatSystem extends System {
|
||||
override update(world: World) {
|
||||
let random: Random | undefined;
|
||||
|
|
@ -19,8 +26,8 @@ export class CombatSystem extends System {
|
|||
}
|
||||
|
||||
let damageSum = 0;
|
||||
const hitEvents: { attackerId: string; sourceId: string | null; amount: number; damageType: string }[] = [];
|
||||
let lastHit: { attackerId: string; sourceId: string | null } | null = null;
|
||||
const hitEvents: HitEvent[] = [];
|
||||
let lastHit: HitEvent | null = null;
|
||||
|
||||
for (const attack of target.getAll(Attacked)) {
|
||||
const { attackerId, sourceId } = attack.state;
|
||||
|
|
@ -51,7 +58,7 @@ export class CombatSystem extends System {
|
|||
if (!random) {
|
||||
random = getWorldRandom(world);
|
||||
}
|
||||
const variedDamage = random.use(r => r.nextInt(-variance, variance));
|
||||
const variedDamage = random.use(r => r.nextInt(-variance, variance + 1));
|
||||
|
||||
if (variedDamage > 0) {
|
||||
damageAmount += variedDamage;
|
||||
|
|
@ -71,9 +78,13 @@ export class CombatSystem extends System {
|
|||
}
|
||||
}
|
||||
|
||||
const defense = target.get(Defense, (c) => c.state.damageType === damageType);
|
||||
if (defense) {
|
||||
damageAmount -= defense.value;
|
||||
const typeDefense = target.get(Defense, (c) => c.state.damageType === damageType);
|
||||
if (typeDefense) {
|
||||
damageAmount -= typeDefense.value;
|
||||
}
|
||||
const generalDefense = target.get(Defense, (c) => c.state.damageType == null);
|
||||
if (generalDefense) {
|
||||
damageAmount -= generalDefense.value;
|
||||
}
|
||||
|
||||
damageAmount = Math.max(0, minDamage, damageAmount);
|
||||
|
|
@ -99,8 +110,8 @@ export class CombatSystem extends System {
|
|||
}
|
||||
|
||||
damageSum += damageAmount;
|
||||
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
||||
lastHit = { attackerId, sourceId };
|
||||
lastHit = { attackerId, sourceId, amount: damageAmount, damageType };
|
||||
hitEvents.push(lastHit);
|
||||
}
|
||||
|
||||
if (damageSum === 0) continue;
|
||||
|
|
@ -109,11 +120,11 @@ export class CombatSystem extends System {
|
|||
health.update(-damageSum);
|
||||
|
||||
for (const info of hitEvents) {
|
||||
target.emit('hit', info);
|
||||
target.emit('Combat.hit', info);
|
||||
}
|
||||
|
||||
if (wasAlive && health.value <= 0 && lastHit) {
|
||||
target.emit('kill', lastHit);
|
||||
target.emit('Combat.killed', lastHit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ export class TextDisplaySystem extends System {
|
|||
const region = data instanceof TextRegion ? data : new TextRegion(data);
|
||||
|
||||
if (absolute || !offset || !viewportClipRect) {
|
||||
this.display.setRegion(x, y, region);
|
||||
this.display.setRegion(region, x, y);
|
||||
} else {
|
||||
const clipRect = this.display.getClipRect();
|
||||
this.display.setClipRect(viewportClipRect);
|
||||
this.display.setRegion(x - offset.x, y - offset.y, region);
|
||||
this.display.setRegion(region, x - offset.x, y - offset.y);
|
||||
this.display.setClipRect(clipRect);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,183 @@
|
|||
import { SeededRandom, ANIMAL_NAME_OPTIONS, FEMALE_NAME_OPTIONS, MALE_NAME_OPTIONS } from "@common/random";
|
||||
import { AlignHorizontal, TextDisplay, TextRegion, type ColorLike } from "@common/display/text";
|
||||
import { gameLoop } from "@common/game";
|
||||
import Input from "@common/input";
|
||||
import { Attacked, Damage } from "@common/rpg/components/combat";
|
||||
import { Equipment } from "@common/rpg/components/equipment";
|
||||
import { Experience, type LevelUpEvent } from "@common/rpg/components/experience";
|
||||
import { Inventory } from "@common/rpg/components/inventory";
|
||||
import { Item, Items } from "@common/rpg/components/item";
|
||||
import { Name } from "@common/rpg/components/name";
|
||||
import { getWorldRandom } from "@common/rpg/components/random";
|
||||
import { Health } from "@common/rpg/components/stat";
|
||||
import { Entity, World } from "@common/rpg/core/world";
|
||||
import { CombatSystem, type HitEvent } from "@common/rpg/systems/combat";
|
||||
import { capitalize } from "@common/utils";
|
||||
|
||||
export default async function main() {
|
||||
const rnd = new SeededRandom(42);
|
||||
const createPlayer = (world: World, weapon: Entity): Entity => {
|
||||
const player = world.createEntity();
|
||||
player.add(new Health({ value: 100, max: 100 }));
|
||||
const inv = player.add(new Inventory());
|
||||
player.add(new Equipment('weapon'));
|
||||
player.add(new Experience({ base: 50, factor: 1.5 }));
|
||||
player.add(new Name("Player"));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(MALE_NAME_OPTIONS));
|
||||
inv.add(weapon);
|
||||
inv.equip(weapon);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
const createEnemy = (world: World, level: number): Entity => {
|
||||
const enemy = world.createEntity();
|
||||
const inv = enemy.add(new Inventory());
|
||||
enemy.add(new Equipment('weapon'));
|
||||
|
||||
const random = getWorldRandom(world);
|
||||
enemy.add(new Name(random.nextName({ maxLength: 7 }) + ' The Goblin'));
|
||||
|
||||
const hp = random.nextInt(level * 10, level * 20);
|
||||
enemy.add(new Health({ value: hp, max: hp }));
|
||||
|
||||
const attack = random.nextInt(level * 3, level * 6);
|
||||
|
||||
const weaponName = random.choice(['sword', 'axe', 'dagger', 'mace']);
|
||||
const weapon = createWeapon(world, weaponName, attack, level);
|
||||
|
||||
inv.add(weapon);
|
||||
inv.equip(weapon);
|
||||
|
||||
return enemy;
|
||||
}
|
||||
|
||||
const createWeapon = (world: World, id: string, damage: number, variance: number): Entity => {
|
||||
const name = id.split(/[-_ ]+/).map(capitalize).join(' ');
|
||||
const weapon = Items.register(world, `${id}_*`, name, { equippable: { slotType: 'weapon' } });
|
||||
|
||||
weapon.add(new Damage({ value: damage, variance }));
|
||||
|
||||
return weapon;
|
||||
}
|
||||
|
||||
const drawEntity = (display: TextDisplay, entity: Entity, x: number, y: number, anchorH: AlignHorizontal) => {
|
||||
const { value: hp, max: maxHp } = entity.get(Health)!;
|
||||
let text = `HP: ${hp}/${maxHp}`;
|
||||
|
||||
const xp = entity.get(Experience);
|
||||
if (xp) {
|
||||
text += `\nLevel: ${xp.level}`;
|
||||
text += `\nXP: ${xp.xp}/${xp.xpForNext}`;
|
||||
}
|
||||
console.log('------------------------------------');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(FEMALE_NAME_OPTIONS));
|
||||
}
|
||||
console.log('------------------------------------');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(rnd.nextName(ANIMAL_NAME_OPTIONS));
|
||||
|
||||
const title = entity.get(Name)?.state ?? entity.id;
|
||||
|
||||
display.drawStringInBox(text, x, y, { title, anchorH });
|
||||
|
||||
const weapon = entity.get(Equipment)!.getItem('weapon');
|
||||
|
||||
if (weapon) {
|
||||
const { value: damage, variance } = weapon.get(Damage)!;
|
||||
const minDamage = damage - variance;
|
||||
const maxDamage = damage + variance;
|
||||
display.drawStringInBox(`Damage: ${minDamage}-${maxDamage}`, x, y + (xp ? 5 : 3), { title: weapon.get(Item)!.name, anchorH });
|
||||
}
|
||||
}
|
||||
|
||||
export default gameLoop(() => {
|
||||
const world = new World();
|
||||
|
||||
world.addSystem(new CombatSystem());
|
||||
const display = new TextDisplay();
|
||||
|
||||
const fists = createWeapon(world, 'fists', 7, 2);
|
||||
const player = createPlayer(world, fists);
|
||||
const enemy = createEnemy(world, 1);
|
||||
|
||||
const log: TextRegion[] = [];
|
||||
const state = {
|
||||
world,
|
||||
display,
|
||||
player,
|
||||
enemy,
|
||||
needUpdate: true,
|
||||
blocked: false,
|
||||
log,
|
||||
addLog(text: string, color?: ColorLike) {
|
||||
log.push(new TextRegion(text, color));
|
||||
if (log.length > 10) {
|
||||
log.shift();
|
||||
}
|
||||
state.needUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
world.on<HitEvent>('Combat.hit', ({ target, data }) => {
|
||||
if (target instanceof Entity) {
|
||||
const targetName = target === player ? "Player" : target.get(Name)?.state ?? target.id;
|
||||
state.addLog(`${targetName} hit by ${data.amount}`);
|
||||
}
|
||||
});
|
||||
|
||||
world.on('Combat.killed', ({ target }) => {
|
||||
if (target instanceof Entity) {
|
||||
target.destroy();
|
||||
player.get(Experience)!.award(10);
|
||||
state.addLog("Enemy killed!");
|
||||
}
|
||||
});
|
||||
|
||||
player.on<LevelUpEvent>('Experience.levelup', ({ data }) => {
|
||||
const health = player.get(Health)!;
|
||||
|
||||
health.applyModifier(Math.round(health.max! * 0.1), 'max');
|
||||
health.set(health.max!);
|
||||
|
||||
state.addLog(`Player leveled up to level ${data.level}`);
|
||||
})
|
||||
|
||||
return state;
|
||||
}, (_dt, state) => {
|
||||
if (Input.isReleased(Input.KeyCode.SPACE, Input.KeyCode.MOUSE_LEFT) && !state.blocked) {
|
||||
const weapon = state.player.get(Equipment)!.getItem('weapon')!;
|
||||
state.enemy.add(new Attacked({ attackerId: state.player.id, sourceId: weapon.id }));
|
||||
state.blocked = true;
|
||||
|
||||
setTimeout(() => {
|
||||
if (state.enemy.destroyed) {
|
||||
state.enemy = createEnemy(state.world, state.player.get(Experience)!.level);
|
||||
} else {
|
||||
const enemyWeapon = state.enemy.get(Equipment)!.getItem('weapon')!;
|
||||
state.player.add(new Attacked({ attackerId: state.enemy.id, sourceId: enemyWeapon.id }));
|
||||
}
|
||||
state.blocked = false;
|
||||
state.needUpdate = true;
|
||||
}, 500);
|
||||
|
||||
state.needUpdate = true;
|
||||
}
|
||||
|
||||
if (state.needUpdate) {
|
||||
state.world.update(1);
|
||||
|
||||
state.display.fillBox(
|
||||
0,
|
||||
0,
|
||||
state.display.width,
|
||||
state.display.height,
|
||||
' ',
|
||||
);
|
||||
|
||||
drawEntity(state.display, state.player, 0, 0, AlignHorizontal.LEFT);
|
||||
if (!state.enemy.destroyed) {
|
||||
drawEntity(state.display, state.enemy, state.display.width - 1, 0, AlignHorizontal.RIGHT);
|
||||
}
|
||||
|
||||
for (let i = 0; i < state.log.length; i++) {
|
||||
const entry = state.log[i];
|
||||
state.display.setRegion(entry, 0, state.display.height - state.log.length + i);
|
||||
}
|
||||
|
||||
state.needUpdate = false;
|
||||
}
|
||||
|
||||
state.display.update();
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@ describe('Equipment — equip', () => {
|
|||
const player = makePlayer(w);
|
||||
const result = player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(result).toBeTrue();
|
||||
expect(player.get(Equipment)!.getItem('weapon')).toBe('sword');
|
||||
expect(player.get(Equipment)!.getItemId('weapon')).toBe('sword');
|
||||
});
|
||||
|
||||
it('returns false for unknown slot', () => {
|
||||
|
|
@ -73,7 +73,7 @@ describe('Equipment — equip', () => {
|
|||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword1' });
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword2' });
|
||||
expect(eq.getItem('weapon')).toBe('sword2');
|
||||
expect(eq.getItemId('weapon')).toBe('sword2');
|
||||
});
|
||||
|
||||
it("emits 'equip' event", () => {
|
||||
|
|
@ -149,7 +149,7 @@ describe('Equipment — unequip', () => {
|
|||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
eq.unequip('weapon');
|
||||
expect(eq.getItem('weapon')).toBeNull();
|
||||
expect(eq.getItemId('weapon')).toBeNull();
|
||||
});
|
||||
|
||||
it('unequip returns false on empty slot', () => {
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ describe('Inventory — equip', () => {
|
|||
const inv = player.get(Inventory)!;
|
||||
inv.add({ itemId: 'sword', amount: 1 });
|
||||
expect(inv.equip({ itemId: 'sword' })).toBeTrue();
|
||||
expect(player.get(Equipment)!.getItem('weapon')).toBe('sword');
|
||||
expect(player.get(Equipment)!.getItemId('weapon')).toBe('sword');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue