diff --git a/src/common/level/bsp.ts b/src/common/level/bsp.ts index 5178272..31d2e64 100644 --- a/src/common/level/bsp.ts +++ b/src/common/level/bsp.ts @@ -58,7 +58,7 @@ export namespace BSP { const splitHorizontally = node.width > node.height; if (splitHorizontally && node.width > minWidth * 2) { - const splitX = random.randInt(minWidth, node.width - minWidth); + const splitX = random.nextInt(minWidth, node.width - minWidth); const nodeA: Node = { id: node.id + '/A', @@ -81,7 +81,7 @@ export namespace BSP { node.children = [nodeA, nodeB]; stack.push(nodeA, nodeB); } else if (!splitHorizontally && node.height > minHeight * 2) { - const splitY = random.randInt(minHeight, node.height - minHeight); + const splitY = random.nextInt(minHeight, node.height - minHeight); const nodeA: Node = { id: node.id + '/A', @@ -116,10 +116,10 @@ export namespace BSP { while (stack.length > 0) { const node = stack.pop()!; if (node.children.length === 0) { - const width = random.randInt(minWidth, node.width) - 2; - const height = random.randInt(minHeight, node.height) - 2; - const x = random.randInt(node.x + 1, node.x + node.width - width - 1); - const y = random.randInt(node.y + 1, node.y + node.height - height - 1); + const width = random.nextInt(minWidth, node.width) - 2; + const height = random.nextInt(minHeight, node.height) - 2; + const x = random.nextInt(node.x + 1, node.x + node.width - width - 1); + const y = random.nextInt(node.y + 1, node.y + node.height - height - 1); node.x = x; node.y = y; @@ -159,11 +159,11 @@ export namespace BSP { } function carveCorridor(nodeA: Node, nodeB: Node, carver: Carver, random: SeededRandom) { - let ax = random.randInt(nodeA.x, nodeA.x + nodeA.width); - let ay = random.randInt(nodeA.y, nodeA.y + nodeA.height); + let ax = random.nextInt(nodeA.x, nodeA.x + nodeA.width); + let ay = random.nextInt(nodeA.y, nodeA.y + nodeA.height); - const bx = random.randInt(nodeB.x, nodeB.x + nodeB.width); - const by = random.randInt(nodeB.y, nodeB.y + nodeB.height); + const bx = random.nextInt(nodeB.x, nodeB.x + nodeB.width); + const by = random.nextInt(nodeB.y, nodeB.y + nodeB.height); const dx = Math.sign(bx - ax); const dy = Math.sign(by - ay); diff --git a/src/common/random.ts b/src/common/random.ts index e5f3a67..779610e 100644 --- a/src/common/random.ts +++ b/src/common/random.ts @@ -105,7 +105,7 @@ export class SeededRandom { * Uniform float in [0, 1). * Uses 32 bits of randomness → 2^32 distinct values spaced by ~2.3 × 10^−10. */ - random(): number { + nextFloat(): number { return this.next() / 0x1_0000_0000; } @@ -116,9 +116,9 @@ export class SeededRandom { * @param min Inclusive lower bound (default 0). * @param max Exclusive upper bound (required). */ - randInt(min: number, max: number): number; - randInt(max: number): number; - randInt(minOrMax: number, max?: number): number { + nextInt(min: number, max: number): number; + nextInt(max: number): number; + nextInt(minOrMax: number, max?: number): number { const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max]; if (!Number.isInteger(min) || !Number.isInteger(hi)) { @@ -139,7 +139,7 @@ export class SeededRandom { } /** Uniform boolean — exactly 50 % probability. */ - randBool(): boolean { + nextBool(): boolean { return (this.next() & 1) === 1; } @@ -161,7 +161,7 @@ export class SeededRandom { if (arr.length === 0) return []; if (k === undefined) { - return arr[this.randInt(0, arr.length)]; + return arr[this.nextInt(0, arr.length)]; } if (k === 0) { @@ -178,7 +178,7 @@ export class SeededRandom { // Partial Fisher-Yates — only iterate the first k swaps. // The selected items accumulate at the front of the working copy. for (let i = 0; i < k; i++) { - const j = this.randInt(i, arr.length); + const j = this.nextInt(i, arr.length); const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } return arr.slice(0, k); @@ -225,7 +225,7 @@ export class SeededRandom { if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0'); const drawOne = (): T => { - let r = this.random() * total; + let r = this.nextFloat() * total; for (let i = 0; i < items.length; i++) { r -= weights[i]; if (r < 0) return items[i]; @@ -250,7 +250,7 @@ export class SeededRandom { */ shuffle(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { - const j = this.randInt(0, i + 1); + const j = this.nextInt(0, i + 1); const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; diff --git a/src/common/rpg/TODO.md b/src/common/rpg/TODO.md index 85ec16b..32c42fd 100644 --- a/src/common/rpg/TODO.md +++ b/src/common/rpg/TODO.md @@ -5,6 +5,3 @@ ### Damage modifiers Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on the attacker/source that CombatSystem folds into the final damage value. - -### Crit / variance -RNG layer on top of damage calculation (crit chance, crit multiplier, random range). diff --git a/src/common/rpg/components/combat.ts b/src/common/rpg/components/combat.ts index fd25e43..0d374d3 100644 --- a/src/common/rpg/components/combat.ts +++ b/src/common/rpg/components/combat.ts @@ -6,7 +6,10 @@ import { Stat } from "./stat"; export class Defense extends Stat<{ damageType: string }> { } @component -export class Damage extends Stat<{ damageType: string, minDamage?: number }> { } +export class Damage extends Stat<{ damageType: string, minDamage?: number, variance?: number }> { } + +@component +export class Crit extends Stat<{ chance: number }> { } @component export class Attacked extends Component<{ attackerId: string; sourceId: string | null }> { } \ No newline at end of file diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 304bbab..69d038b 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -10,27 +10,36 @@ abstract class BaseEffect extends Component<{ targetField: 'value' | 'max' | 'min'; delta: number; duration: number | null; // null = permanent until removed + gradual: boolean; // true if delta should be applied slowly over duration + permanent: boolean; // true if effect should not be removed by clear() remaining: number | null; // countdown in seconds; null for condition-based/permanent condition: string | null; // keep effect while true; remove when it becomes false stacking: 'stack' | 'unique' | 'replace'; tag: string | null; // discriminator for stacking; null = no stacking enforcement }> { constructor(opts: { - target?: Class>, + target?: Class> | string, targetKey?: string; delta: number; targetField?: 'value' | 'max' | 'min'; duration?: number; + gradual?: boolean; + permanent?: boolean; condition?: string; stacking?: 'stack' | 'unique' | 'replace'; tag?: string; + perSecond?: boolean; }) { super({ - target: getComponentName(opts.target ?? Stat)!, + target: typeof opts.target === 'string' + ? opts.target + : getComponentName(opts.target ?? Stat)!, targetKey: opts.targetKey, targetField: opts.targetField ?? 'value', delta: opts.delta, duration: opts.duration ?? null, + gradual: opts.gradual ?? false, + permanent: opts.permanent ?? false, remaining: opts.duration ?? null, condition: opts.condition ?? null, stacking: opts.stacking ?? 'stack', @@ -64,30 +73,30 @@ export class Effect extends BaseEffect { } } - const component = getComponentMeta(this.state.target); - if (component) { - const stat = this.entity.get(component.ctor, this.state.targetKey); - if (stat instanceof Stat) { - stat.applyModifier(this.state.delta, this.state.targetField); - this.active = true; - } + if (this.state.gradual) { + this.active = true; + } else { + this.applyDelta(this.state.delta); + } + + if (this.state.remaining || this.state.condition) { + this.ensureEffectSystem(); } } override onRemove(): void { if (!this.active) return; - const component = getComponentMeta(this.state.target); - if (component) { - const stat = this.entity.get(component.ctor, this.state.targetKey); - if (stat instanceof Stat) { - stat.removeModifier(this.state.delta, this.state.targetField); - } - } this.active = false; + + if (this.state.permanent || this.state.gradual) return; + + this.applyDelta(-this.state.delta); + this.active = false; // applyDelta sets active to true again } @action reset(duration?: number): void { + this.ensureEffectSystem(); if (duration != null) { this.state.duration = duration; } @@ -98,6 +107,7 @@ export class Effect extends BaseEffect { /** Mark as expired. EffectSystem will remove the component, which reverses the delta. */ @action clear(): void { + this.ensureEffectSystem(); this.state.remaining = 0; this.emit('expired'); } @@ -107,19 +117,42 @@ export class Effect extends BaseEffect { if (this.state.remaining > 0) { this.state.remaining -= dt; if (this.state.remaining <= 0) { - this.state.remaining = 0; this.clear(); } + + if (this.state.gradual && this.state.duration) { + const delta = this.state.delta * dt / this.state.duration; + this.applyDelta(delta); + } } } else if (this.state.condition != null) { if (!evaluateCondition(this.state.condition, ctx)) { this.clear(); } } - // permanent effect (no duration, no condition): nothing to do + // static effect (no duration, no condition): nothing to do + } + + private applyDelta(delta: number) { + const component = getComponentMeta(this.state.target); + if (component) { + const stat = this.entity.get(component.ctor, this.state.targetKey); + if (stat instanceof Stat) { + stat.applyModifier(delta, this.state.targetField); + this.active = true; + } + } + } + + private async ensureEffectSystem() { + const { EffectSystem } = await import('@common/rpg/systems/effect'); + + if (!this.world.hasSystem(EffectSystem)) { + this.world.addSystem(new EffectSystem()); + } } } @component export class EffectTemplate extends BaseEffect { -} \ No newline at end of file +} diff --git a/src/common/rpg/components/random.ts b/src/common/rpg/components/random.ts new file mode 100644 index 0000000..d5f289f --- /dev/null +++ b/src/common/rpg/components/random.ts @@ -0,0 +1,38 @@ +import { 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 }> { + 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(); + })() + }); + } + + use(fn: (rng: SeededRandom) => T): T { + if (this.rng) { + this.rng.setState(this.state.random); + } else { + this.rng = new SeededRandom(this.state.random); + } + const result = fn(this.rng); + this.state.random = this.rng.getState(); + return result; + } +} + +export const getWorldRandom = (world: World): Random => { + const random = world.findComponent(Random); + if (random) { + return random; + } + + const entity = world.createEntity('random_*'); + return entity.add(new Random()); +} diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index 91c275c..07418c6 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -1,5 +1,6 @@ -import { Attacked, Damage, Defense } from "../components/combat"; +import { Attacked, Crit, Damage, Defense } from "../components/combat"; import { Effect, EffectTemplate } from "../components/effect"; +import { getWorldRandom, Random } from "../components/random"; import { Health } from "../components/stat"; import { System, World } from "../core/world"; @@ -7,6 +8,8 @@ let hitEffectCounter = 0; export class CombatSystem extends System { override update(world: World) { + let random: Random | undefined; + for (const [target] of world.query(Attacked)) { const health = target.get(Health); if (!health) { @@ -41,31 +44,50 @@ export class CombatSystem extends System { continue; } - const { damageType, minDamage = 0 } = damage.state; + const { damageType, minDamage = 0, variance = 0 } = damage.state; let damageAmount = damage.value; + if (variance > 0) { + if (!random) { + random = getWorldRandom(world); + } + damageAmount += random.use(r => r.nextInt(-variance, variance)); + } + + const crit = source.get(Crit); + + if (crit) { + const { chance } = crit.state; + if (!random) { + random = getWorldRandom(world); + } + const roll = random.use(r => r.nextFloat()); + if (roll < chance) { + damageAmount *= crit.value; + } + } + const defense = target.get(Defense, (c) => c.state.damageType === damageType); if (defense) { damageAmount = Math.max(minDamage, damageAmount - defense.value); } // Apply on-hit effects from source onto target - for (const [key, component] of source) { - if (component instanceof EffectTemplate) { - const s = component.state; - target.add( - new Effect({ - targetKey: s.targetKey, - delta: s.delta, - targetField: s.targetField, - duration: s.duration ?? undefined, - condition: s.condition ?? undefined, - stacking: s.stacking, - tag: s.tag ?? undefined, - }), - `__hit_${source.id}_${key}_${hitEffectCounter++}`, - ); - } + for (const component of source.getAll(EffectTemplate)) { + const s = component.state; + target.add( + new Effect({ + target: s.target, + targetKey: s.targetKey, + delta: s.delta, + targetField: s.targetField, + duration: s.duration ?? undefined, + condition: s.condition ?? undefined, + stacking: s.stacking, + tag: s.tag ?? undefined, + }), + `__hit_${source.id}_${component.key}_${hitEffectCounter++}`, + ); } damageSum += damageAmount;