1
0
Fork 0

Crit, random variance, gradual and permanent effects

This commit is contained in:
Pabloader 2026-05-07 15:56:31 +00:00
parent d5eaac4099
commit 6b19eebd53
7 changed files with 153 additions and 60 deletions

View File

@ -58,7 +58,7 @@ export namespace BSP {
const splitHorizontally = node.width > node.height; const splitHorizontally = node.width > node.height;
if (splitHorizontally && node.width > minWidth * 2) { 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 = { const nodeA: Node = {
id: node.id + '/A', id: node.id + '/A',
@ -81,7 +81,7 @@ export namespace BSP {
node.children = [nodeA, nodeB]; node.children = [nodeA, nodeB];
stack.push(nodeA, nodeB); stack.push(nodeA, nodeB);
} else if (!splitHorizontally && node.height > minHeight * 2) { } 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 = { const nodeA: Node = {
id: node.id + '/A', id: node.id + '/A',
@ -116,10 +116,10 @@ export namespace BSP {
while (stack.length > 0) { while (stack.length > 0) {
const node = stack.pop()!; const node = stack.pop()!;
if (node.children.length === 0) { if (node.children.length === 0) {
const width = random.randInt(minWidth, node.width) - 2; const width = random.nextInt(minWidth, node.width) - 2;
const height = random.randInt(minHeight, node.height) - 2; const height = random.nextInt(minHeight, node.height) - 2;
const x = random.randInt(node.x + 1, node.x + node.width - width - 1); const x = random.nextInt(node.x + 1, node.x + node.width - width - 1);
const y = random.randInt(node.y + 1, node.y + node.height - height - 1); const y = random.nextInt(node.y + 1, node.y + node.height - height - 1);
node.x = x; node.x = x;
node.y = y; node.y = y;
@ -159,11 +159,11 @@ export namespace BSP {
} }
function carveCorridor(nodeA: Node, nodeB: Node, carver: Carver, random: SeededRandom) { function carveCorridor(nodeA: Node, nodeB: Node, carver: Carver, random: SeededRandom) {
let ax = random.randInt(nodeA.x, nodeA.x + nodeA.width); let ax = random.nextInt(nodeA.x, nodeA.x + nodeA.width);
let ay = random.randInt(nodeA.y, nodeA.y + nodeA.height); let ay = random.nextInt(nodeA.y, nodeA.y + nodeA.height);
const bx = random.randInt(nodeB.x, nodeB.x + nodeB.width); const bx = random.nextInt(nodeB.x, nodeB.x + nodeB.width);
const by = random.randInt(nodeB.y, nodeB.y + nodeB.height); const by = random.nextInt(nodeB.y, nodeB.y + nodeB.height);
const dx = Math.sign(bx - ax); const dx = Math.sign(bx - ax);
const dy = Math.sign(by - ay); const dy = Math.sign(by - ay);

View File

@ -105,7 +105,7 @@ export class SeededRandom {
* Uniform float in [0, 1). * Uniform float in [0, 1).
* Uses 32 bits of randomness 2^32 distinct values spaced by ~2.3 × 10^10. * 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; return this.next() / 0x1_0000_0000;
} }
@ -116,9 +116,9 @@ export class SeededRandom {
* @param min Inclusive lower bound (default 0). * @param min Inclusive lower bound (default 0).
* @param max Exclusive upper bound (required). * @param max Exclusive upper bound (required).
*/ */
randInt(min: number, max: number): number; nextInt(min: number, max: number): number;
randInt(max: number): number; nextInt(max: number): number;
randInt(minOrMax: number, max?: number): number { nextInt(minOrMax: number, max?: number): number {
const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max]; const [min, hi] = max === undefined ? [0, minOrMax] : [minOrMax, max];
if (!Number.isInteger(min) || !Number.isInteger(hi)) { if (!Number.isInteger(min) || !Number.isInteger(hi)) {
@ -139,7 +139,7 @@ export class SeededRandom {
} }
/** Uniform boolean — exactly 50 % probability. */ /** Uniform boolean — exactly 50 % probability. */
randBool(): boolean { nextBool(): boolean {
return (this.next() & 1) === 1; return (this.next() & 1) === 1;
} }
@ -161,7 +161,7 @@ export class SeededRandom {
if (arr.length === 0) return []; if (arr.length === 0) return [];
if (k === undefined) { if (k === undefined) {
return arr[this.randInt(0, arr.length)]; return arr[this.nextInt(0, arr.length)];
} }
if (k === 0) { if (k === 0) {
@ -178,7 +178,7 @@ export class SeededRandom {
// Partial Fisher-Yates — only iterate the first k swaps. // Partial Fisher-Yates — only iterate the first k swaps.
// The selected items accumulate at the front of the working copy. // The selected items accumulate at the front of the working copy.
for (let i = 0; i < k; i++) { 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; const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
} }
return arr.slice(0, k); return arr.slice(0, k);
@ -225,7 +225,7 @@ export class SeededRandom {
if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0'); if (total <= 0) throw new RangeError('weightedChoice: total weight must be > 0');
const drawOne = (): T => { const drawOne = (): T => {
let r = this.random() * total; let r = this.nextFloat() * total;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
r -= weights[i]; r -= weights[i];
if (r < 0) return items[i]; if (r < 0) return items[i];
@ -250,7 +250,7 @@ export class SeededRandom {
*/ */
shuffle<T>(arr: T[]): T[] { shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) { 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]; const tmp = arr[i];
arr[i] = arr[j]; arr[i] = arr[j];
arr[j] = tmp; arr[j] = tmp;

View File

@ -5,6 +5,3 @@
### Damage modifiers ### Damage modifiers
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
the attacker/source that CombatSystem folds into the final damage value. 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).

View File

@ -6,7 +6,10 @@ import { Stat } from "./stat";
export class Defense extends Stat<{ damageType: string }> { } export class Defense extends Stat<{ damageType: string }> { }
@component @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 @component
export class Attacked extends Component<{ attackerId: string; sourceId: string | null }> { } export class Attacked extends Component<{ attackerId: string; sourceId: string | null }> { }

View File

@ -10,27 +10,36 @@ abstract class BaseEffect extends Component<{
targetField: 'value' | 'max' | 'min'; targetField: 'value' | 'max' | 'min';
delta: number; delta: number;
duration: number | null; // null = permanent until removed 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 remaining: number | null; // countdown in seconds; null for condition-based/permanent
condition: string | null; // keep effect while true; remove when it becomes false condition: string | null; // keep effect while true; remove when it becomes false
stacking: 'stack' | 'unique' | 'replace'; stacking: 'stack' | 'unique' | 'replace';
tag: string | null; // discriminator for stacking; null = no stacking enforcement tag: string | null; // discriminator for stacking; null = no stacking enforcement
}> { }> {
constructor(opts: { constructor(opts: {
target?: Class<Component<any>>, target?: Class<Component<any>> | string,
targetKey?: string; targetKey?: string;
delta: number; delta: number;
targetField?: 'value' | 'max' | 'min'; targetField?: 'value' | 'max' | 'min';
duration?: number; duration?: number;
gradual?: boolean;
permanent?: boolean;
condition?: string; condition?: string;
stacking?: 'stack' | 'unique' | 'replace'; stacking?: 'stack' | 'unique' | 'replace';
tag?: string; tag?: string;
perSecond?: boolean;
}) { }) {
super({ super({
target: getComponentName(opts.target ?? Stat)!, target: typeof opts.target === 'string'
? opts.target
: getComponentName(opts.target ?? Stat)!,
targetKey: opts.targetKey, targetKey: opts.targetKey,
targetField: opts.targetField ?? 'value', targetField: opts.targetField ?? 'value',
delta: opts.delta, delta: opts.delta,
duration: opts.duration ?? null, duration: opts.duration ?? null,
gradual: opts.gradual ?? false,
permanent: opts.permanent ?? false,
remaining: opts.duration ?? null, remaining: opts.duration ?? null,
condition: opts.condition ?? null, condition: opts.condition ?? null,
stacking: opts.stacking ?? 'stack', stacking: opts.stacking ?? 'stack',
@ -64,30 +73,30 @@ export class Effect extends BaseEffect {
} }
} }
const component = getComponentMeta(this.state.target); if (this.state.gradual) {
if (component) { this.active = true;
const stat = this.entity.get(component.ctor, this.state.targetKey); } else {
if (stat instanceof Stat) { this.applyDelta(this.state.delta);
stat.applyModifier(this.state.delta, this.state.targetField); }
this.active = true;
} if (this.state.remaining || this.state.condition) {
this.ensureEffectSystem();
} }
} }
override onRemove(): void { override onRemove(): void {
if (!this.active) return; 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; 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 @action
reset(duration?: number): void { reset(duration?: number): void {
this.ensureEffectSystem();
if (duration != null) { if (duration != null) {
this.state.duration = duration; 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. */ /** Mark as expired. EffectSystem will remove the component, which reverses the delta. */
@action @action
clear(): void { clear(): void {
this.ensureEffectSystem();
this.state.remaining = 0; this.state.remaining = 0;
this.emit('expired'); this.emit('expired');
} }
@ -107,19 +117,42 @@ export class Effect extends BaseEffect {
if (this.state.remaining > 0) { if (this.state.remaining > 0) {
this.state.remaining -= dt; this.state.remaining -= dt;
if (this.state.remaining <= 0) { if (this.state.remaining <= 0) {
this.state.remaining = 0;
this.clear(); 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) { } else if (this.state.condition != null) {
if (!evaluateCondition(this.state.condition, ctx)) { if (!evaluateCondition(this.state.condition, ctx)) {
this.clear(); 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 @component
export class EffectTemplate extends BaseEffect { export class EffectTemplate extends BaseEffect {
} }

View File

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

View File

@ -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 { Effect, EffectTemplate } from "../components/effect";
import { getWorldRandom, Random } from "../components/random";
import { Health } from "../components/stat"; import { Health } from "../components/stat";
import { System, World } from "../core/world"; import { System, World } from "../core/world";
@ -7,6 +8,8 @@ let hitEffectCounter = 0;
export class CombatSystem extends System { export class CombatSystem extends System {
override update(world: World) { override update(world: World) {
let random: Random | undefined;
for (const [target] of world.query(Attacked)) { for (const [target] of world.query(Attacked)) {
const health = target.get(Health); const health = target.get(Health);
if (!health) { if (!health) {
@ -41,31 +44,50 @@ export class CombatSystem extends System {
continue; continue;
} }
const { damageType, minDamage = 0 } = damage.state; const { damageType, minDamage = 0, variance = 0 } = damage.state;
let damageAmount = damage.value; 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); const defense = target.get(Defense, (c) => c.state.damageType === damageType);
if (defense) { if (defense) {
damageAmount = Math.max(minDamage, damageAmount - defense.value); damageAmount = Math.max(minDamage, damageAmount - defense.value);
} }
// Apply on-hit effects from source onto target // Apply on-hit effects from source onto target
for (const [key, component] of source) { for (const component of source.getAll(EffectTemplate)) {
if (component instanceof EffectTemplate) { const s = component.state;
const s = component.state; target.add(
target.add( new Effect({
new Effect({ target: s.target,
targetKey: s.targetKey, targetKey: s.targetKey,
delta: s.delta, delta: s.delta,
targetField: s.targetField, targetField: s.targetField,
duration: s.duration ?? undefined, duration: s.duration ?? undefined,
condition: s.condition ?? undefined, condition: s.condition ?? undefined,
stacking: s.stacking, stacking: s.stacking,
tag: s.tag ?? undefined, tag: s.tag ?? undefined,
}), }),
`__hit_${source.id}_${key}_${hitEffectCounter++}`, `__hit_${source.id}_${component.key}_${hitEffectCounter++}`,
); );
}
} }
damageSum += damageAmount; damageSum += damageAmount;