1
0
Fork 0

Compare commits

...

3 Commits

13 changed files with 367 additions and 66 deletions

View File

@ -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) {

View File

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

View File

@ -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.

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { Component } from "../core/world";
import { component } from "../utils/decorators";
@component
export class Name extends Component<string> { }

View File

@ -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) {

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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