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

View File

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

View File

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

View File

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

View File

@ -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<Component<any>>,
target?: Class<Component<any>> | 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 {
}
}

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 { 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;