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;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
|
||||||
|
|
@ -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 }> { }
|
||||||
|
|
@ -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,16 +117,39 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue