Crit, random variance, gradual and permanent effects
This commit is contained in:
parent
d5eaac4099
commit
6b19eebd53
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }> { }
|
||||
|
|
@ -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);
|
||||
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,16 +117,39 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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,20 +44,40 @@ 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) {
|
||||
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,
|
||||
|
|
@ -63,10 +86,9 @@ export class CombatSystem extends System {
|
|||
stacking: s.stacking,
|
||||
tag: s.tag ?? undefined,
|
||||
}),
|
||||
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
|
||||
`__hit_${source.id}_${component.key}_${hitEffectCounter++}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
damageSum += damageAmount;
|
||||
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
||||
|
|
|
|||
Loading…
Reference in New Issue