feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
This commit is contained in:
1 parent
2580152a3a
commit
ae22335cfe
65 files changed
+616
-141
No files matched your search
@@ -34,8 +34,12 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a
|
||||
against the spec (`server/match/fields.js` `validateClientResult`), runs the battles no connected human owns (bots, a
|
||||
dropped player, an authority that missed its deadline) and can re-simulate results (`SP_VERIFY`). `SP_COMBAT=server`
|
||||
is the older mode in which the server runs every battle and streams snapshots. DESIGN §14.
|
||||
- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock and
|
||||
no `Math.random` in the sim, so the same spec and data give the same battle on every machine, bit for bit. The golden
|
||||
- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock,
|
||||
no `Math.random` and only correctly rounded arithmetic in the sim, so the same spec and data give the same battle on
|
||||
every machine, bit for bit. ECMA-262 leaves `Math.hypot`, `sin`, `cos`, `atan2`, `pow` (and `**`) implementation-
|
||||
approximated and the engines differ in their last bits (V8, SpiderMonkey and JavaScriptCore each compute `hypot`
|
||||
differently; Chrome's and Node's `sin` differ), so the sim takes them from `server/sim/detmath.js`, built only from
|
||||
+ − × ÷ and `Math.sqrt`; ESLint and `test/sim/detmath.test.js` refuse the Math ones in `server/sim/`. The golden
|
||||
results (§5) rely on it.
|
||||
|
||||
## 2. The WebSocket protocol
|
||||
|
||||
+6
-2
@@ -3,7 +3,9 @@
|
||||
Audience: **content authors** (kits, bonds, garrisons, items, bands, enemies, bosses, devices, choices) and the
|
||||
**match owner** who drives `Battle`. The normative contract is DESIGN.md §5 ([design/engine.md](design/engine.md));
|
||||
this file documents the concrete implementation, every hook and helper, the SkillSpec schema with worked examples,
|
||||
the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`.
|
||||
the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`,
|
||||
and the only floating-point functions are correctly rounded ones (`detmath.js` below), so a battle gives the same bits in
|
||||
every browser and on the server.
|
||||
|
||||
```
|
||||
server/sim/
|
||||
@@ -13,6 +15,8 @@ server/sim/
|
||||
tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events
|
||||
constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs
|
||||
rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted)
|
||||
detmath.js hypot / powi / sin / cos / atan2 with the same bits on every engine — the sim never calls the
|
||||
implementation-approximated Math functions (hypot, sin, cos, atan2, pow, ** …; lint + test enforce it)
|
||||
grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles
|
||||
units.js Unit model + stat aggregation
|
||||
buffs.js Buff model, mod keys, status catalogue
|
||||
@@ -851,7 +855,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) |
|
||||
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
|
||||
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
|
||||
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
|
||||
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random; for distances, angles and powers use `hypot`, `sin`, `cos`, `atan2`, `powi` from `server/sim/detmath.js`, never `Math.hypot` / `sin` / `cos` / `atan2` / `pow` / `**` (their last bits differ between browsers) |
|
||||
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
|
||||
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable, standable (`canStand`: never the 深水区) tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
|
||||
| `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
|
||||
|
||||
@@ -39,6 +39,10 @@ const rules = {
|
||||
'preserve-caught-error': 'warn',
|
||||
};
|
||||
|
||||
// ECMA-262 §21.3.2: the Math functions whose results are "implementation-approximated".
|
||||
const APPROXIMATED_MATH = ['acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', 'atan2', 'cbrt', 'cos', 'cosh', 'exp',
|
||||
'expm1', 'hypot', 'log', 'log1p', 'log10', 'log2', 'pow', 'sin', 'sinh', 'tan', 'tanh'];
|
||||
|
||||
const nodeFiles = [
|
||||
'server/**/*.js',
|
||||
'shared/**/*.js',
|
||||
@@ -79,6 +83,20 @@ export default [
|
||||
},
|
||||
rules,
|
||||
},
|
||||
{
|
||||
// The sim runs in every player's browser and on the server, and must give the same bits everywhere. ECMA-262
|
||||
// leaves these Math functions (and **) implementation-approximated: engines differ in the last bits.
|
||||
files: ['server/sim/**/*.js'],
|
||||
rules: {
|
||||
'no-restricted-properties': ['error', ...APPROXIMATED_MATH.map((property) => ({
|
||||
object: 'Math', property, message: 'implementation-approximated (engines differ in the last bits): use server/sim/detmath.js',
|
||||
}))],
|
||||
'no-restricted-syntax': ['error',
|
||||
{ selector: "BinaryExpression[operator='**']", message: 'implementation-approximated: use powi from server/sim/detmath.js' },
|
||||
{ selector: "AssignmentExpression[operator='**=']", message: 'implementation-approximated: use powi from server/sim/detmath.js' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Node test runner, plus browser tests whose page.evaluate callbacks use DOM globals.
|
||||
// PIXI is the page global those render tests read inside evaluate().
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"include": [
|
||||
"shared/**/*.js",
|
||||
"server/sim/rng.js",
|
||||
"server/sim/detmath.js",
|
||||
"server/sim/dir.js",
|
||||
"server/sim/constants.js",
|
||||
"types/**/*.js"
|
||||
|
||||
+11
-10
@@ -38,6 +38,7 @@ import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileK
|
||||
import { reduceElement } from './damage.js';
|
||||
import { straightClear } from './grid.js';
|
||||
import { moveFeared, endFear } from './fear.js';
|
||||
import { hypot, powi } from './detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// profiles
|
||||
@@ -325,13 +326,13 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) {
|
||||
if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue;
|
||||
const d = Math.hypot(e.x - prev.x, e.y - prev.y);
|
||||
const d = hypot(e.x - prev.x, e.y - prev.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||||
}
|
||||
if (!best) break;
|
||||
hit.add(best.id);
|
||||
b._ev(['atk', prev.id, best.id, 'chain']);
|
||||
const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * Math.pow(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId });
|
||||
const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * powi(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId });
|
||||
dealtTotal += d;
|
||||
if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u });
|
||||
if (each) each(best, d, 'chain');
|
||||
@@ -389,7 +390,7 @@ function doHeal(b, u, prof, t) {
|
||||
if (!best) break;
|
||||
seen.add(best.id);
|
||||
b._ev(['atk', prev.id, best.id, 'chainHeal']);
|
||||
b.heal(u, best, amount * Math.pow(1 - (h.falloff ?? 0.25), k));
|
||||
b.heal(u, best, amount * powi(1 - (h.falloff ?? 0.25), k));
|
||||
prev = best;
|
||||
}
|
||||
}
|
||||
@@ -475,7 +476,7 @@ function planLeg(b, e, leg) {
|
||||
R.version = b.grid.version;
|
||||
// suffix lengths for remaining-distance queries
|
||||
const suf = new Float64Array(pts.length);
|
||||
for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
||||
for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
||||
R.suffix = suf;
|
||||
}
|
||||
|
||||
@@ -497,7 +498,7 @@ function tailLength(b, e, idx) {
|
||||
let len = 0;
|
||||
if (L.t === 'move') {
|
||||
const [sr, sc] = starts[i];
|
||||
len = Math.hypot(L.r - sr, L.c - sc);
|
||||
len = hypot(L.r - sr, L.c - sc);
|
||||
if (e.motion !== 'FLY' && b.grid.inBounds(sr, sc)) {
|
||||
const f = legField(b, L.r, L.c, sr * COLS + sc);
|
||||
const fl = f ? b.grid.fieldLength(f, sr * COLS + sc) : Infinity;
|
||||
@@ -519,9 +520,9 @@ export function remainingDistance(b, e) {
|
||||
const leg = R.legs[R.legIdx];
|
||||
if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) {
|
||||
const p = R.pts[R.ptIdx];
|
||||
d += Math.hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx];
|
||||
d += hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx];
|
||||
} else if (leg && leg.t === 'move') {
|
||||
d += Math.hypot(leg.c - e.x, leg.r - e.y);
|
||||
d += hypot(leg.c - e.x, leg.r - e.y);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -614,7 +615,7 @@ function moveAttracted(b, e, dt) {
|
||||
let moved = false;
|
||||
while (dist > 1e-9 && A.i < A.pts.length) {
|
||||
const p = A.pts[A.i];
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy);
|
||||
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
|
||||
moved = true;
|
||||
}
|
||||
@@ -667,7 +668,7 @@ function advanceRoute(b, e, dt, R, standing = false) {
|
||||
if (R.ptIdx >= R.pts.length) break;
|
||||
const p = R.pts[R.ptIdx];
|
||||
const dx = p.x - e.x, dy = p.y - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = hypot(dx, dy);
|
||||
if (d <= dist) {
|
||||
e.x = p.x; e.y = p.y;
|
||||
dist -= d;
|
||||
@@ -848,7 +849,7 @@ function enemyAttack(b, e, prevCd) {
|
||||
if (!tt || !tt.alive || !e.alive && !rangedShot) return;
|
||||
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId, isProjectile });
|
||||
};
|
||||
if (rangedShot && Math.hypot(t.x - e.x, t.y - e.y) > 0.75) {
|
||||
if (rangedShot && hypot(t.x - e.x, t.y - e.y) > 0.75) {
|
||||
b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target, true) });
|
||||
} else hit(t);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST } from '../constants.js';
|
||||
import { fin } from './util.js';
|
||||
import { hypot } from '../detmath.js';
|
||||
|
||||
/**
|
||||
* Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效
|
||||
@@ -42,10 +43,10 @@ export class BattleDisplacement {
|
||||
if (!this._displaceable(e)) return 0;
|
||||
let level = this.forceLevel(e, force);
|
||||
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
|
||||
const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy);
|
||||
const vx = e.x - fx0, vy = e.y - fy0, d = hypot(vx, vy);
|
||||
let ux = 0, uy = 0;
|
||||
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0;
|
||||
const dl = Math.hypot(dirX, dirY);
|
||||
const dl = hypot(dirX, dirY);
|
||||
if (dl > 0) {
|
||||
ux = dirX / dl; uy = dirY / dl;
|
||||
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) {
|
||||
@@ -71,7 +72,7 @@ export class BattleDisplacement {
|
||||
// an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
|
||||
if (center && center.side === 'ally' && e.blockedBy === center) return 0;
|
||||
const tx = fin(to.x, e.x), ty = fin(to.y, e.y);
|
||||
const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy);
|
||||
const dx = tx - e.x, dy = ty - e.y, d0 = hypot(dx, dy);
|
||||
if (!(d0 > 1e-6)) return 0;
|
||||
const ux = dx / d0, uy = dy / d0;
|
||||
// travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to`
|
||||
@@ -122,7 +123,7 @@ export class BattleDisplacement {
|
||||
displace(e, dir, distance) {
|
||||
if (!this._displaceable(e) || !dir) return 0;
|
||||
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
|
||||
const len = Math.hypot(dxv, dyv);
|
||||
const len = hypot(dxv, dyv);
|
||||
if (!(len > 0)) return 0;
|
||||
const eff = Math.min(fin(distance, 0), 2 * COLS);
|
||||
if (!(eff > 0)) return 0;
|
||||
|
||||
+3
-2
@@ -24,6 +24,7 @@
|
||||
// `hitRect`).
|
||||
|
||||
import { ROWS, COLS } from './constants.js';
|
||||
import { hypot } from './detmath.js';
|
||||
|
||||
/**
|
||||
* Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit.
|
||||
@@ -83,10 +84,10 @@ export function bodyInKeys(u, keys) {
|
||||
/** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */
|
||||
export function bodyDist(u, x, y) {
|
||||
const R = hitRect(u);
|
||||
if (!R) return Math.hypot(u.x - x, u.y - y);
|
||||
if (!R) return hypot(u.x - x, u.y - y);
|
||||
const dx = x < R.x0 ? R.x0 - x : x > R.x1 ? x - R.x1 : 0;
|
||||
const dy = y < R.y0 ? R.y0 - y : y > R.y1 ? y - R.y1 : 0;
|
||||
return Math.hypot(dx, dy);
|
||||
return hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@
|
||||
// Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks).
|
||||
|
||||
import { COLD_ASPD, COLD_FREEZE_DURATION, FREEZE_RES_DOWN, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
|
||||
import { powi } from './detmath.js';
|
||||
|
||||
/** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */
|
||||
function resistPalsyDecay({ battle, unit }) {
|
||||
@@ -200,12 +201,12 @@ export function aggregateMods(buffs) {
|
||||
const v = m[k];
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
|
||||
if (k === 'shield') continue;
|
||||
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : Math.pow(v, st));
|
||||
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : powi(v, st));
|
||||
else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) {
|
||||
const p = Math.min(1, v);
|
||||
const d = dodge[k];
|
||||
if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) };
|
||||
else { d.n += st; d.miss *= Math.pow(1 - p, st); }
|
||||
if (!d) dodge[k] = { n: st, p, miss: powi(1 - p, st) };
|
||||
else { d.n += st; d.miss *= powi(1 - p, st); }
|
||||
} else {
|
||||
add[k] = (add[k] ?? 0) + v * st;
|
||||
if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st;
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
ensureInstalled, abOf, attach, T, elem, hurt, targetsNear, allTargets, byPriority, areaAllies, areaAlliesInTiles, fieldAllies,
|
||||
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
|
||||
} from './enemies.js';
|
||||
import { hypot, powi } from '../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants
|
||||
@@ -209,7 +210,7 @@ function rawRouteOf(b, e, tpl) {
|
||||
function toGoal(b, x, y, motion = 'WALK') {
|
||||
const ends = b.grid.specialTiles('end');
|
||||
let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity;
|
||||
for (const p of ends) { const d = Math.hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
|
||||
for (const p of ends) { const d = hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
|
||||
return { motion, start: [y, x], end: best, checkpoints: [] };
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ function mirror(b, e, tpl) {
|
||||
const opsOnly = (list) => list.filter((u) => u.kind === 'op');
|
||||
const nearestOf = (b, e, pred) => {
|
||||
let best = null, bd = Infinity;
|
||||
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = Math.hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
|
||||
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
|
||||
return best;
|
||||
};
|
||||
/** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */
|
||||
@@ -370,7 +371,7 @@ function stunBlast(b, src, by, r, c, stun, dot, dur, kind) {
|
||||
function segDist(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
|
||||
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
return hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -708,7 +709,7 @@ function kitSpring(ab, e) {
|
||||
const d = e2.mem.ab.dash;
|
||||
if (!d) return;
|
||||
const g = d.gun && d.gun.alive ? d.gun : gun(b);
|
||||
const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || Math.hypot(g.x - e2.x, g.y - e2.y) < 1;
|
||||
const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || hypot(g.x - e2.x, g.y - e2.y) < 1;
|
||||
// 追逐模式 "对进入自身0.35半径范围内的我方单位(包括飞行单位)造成一次攻击力100%的物理普通伤害" (until 0.1.3: radius 0.5)
|
||||
for (const u of areaAllies(b, e2, e2.x, e2.y, CHARGE_RADIUS)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); }
|
||||
if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; }
|
||||
@@ -730,9 +731,9 @@ function kitSpring(ab, e) {
|
||||
for (let k = 0; k < times && t; k++) {
|
||||
hit.add(t);
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
|
||||
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
|
||||
elem(b, e2, t, 'erosion', e2.s.atk * ratio * powi(SPRING_BOUNCE_FALLOFF, k));
|
||||
const prev = t;
|
||||
t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
|
||||
t = areaAllies(b, e2, prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => hypot(p.x - prev.x, p.y - prev.y) - hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
|
||||
}
|
||||
} else { // 十连击
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
|
||||
|
||||
@@ -37,6 +37,7 @@ import { COLS, ROWS } from '../constants.js';
|
||||
import { performAttack } from '../ai.js';
|
||||
import { sortEnemyTargets } from '../targeting.js';
|
||||
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
|
||||
import { hypot } from '../detmath.js';
|
||||
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
||||
/** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */
|
||||
@@ -192,7 +193,7 @@ function deviceTile(battle, r0, c0) {
|
||||
const exact = r === r0 && c === c0;
|
||||
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
|
||||
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0;
|
||||
const s = [exact ? 0 : 1, walk, Math.hypot(r - r0, c - c0), r * COLS + c];
|
||||
const s = [exact ? 0 : 1, walk, hypot(r - r0, c - c0), r * COLS + c];
|
||||
let less = !bs;
|
||||
if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
|
||||
if (less) { best = [r, c]; bs = s; }
|
||||
@@ -455,7 +456,7 @@ function tickAirflowEnemy(battle, st, e) {
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null;
|
||||
const vx = e.x - px, vy = e.y - py;
|
||||
const v = Math.hypot(vx, vy);
|
||||
const v = hypot(vx, vy);
|
||||
if (f && v > 1e-6) {
|
||||
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v);
|
||||
if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
|
||||
|
||||
@@ -116,6 +116,7 @@ import { REFLECTION_KITS } from './enemies/reflection.js';
|
||||
import { FLY_KITS } from './enemies/fly.js';
|
||||
import { SPECIAL_KITS } from './enemies/special.js';
|
||||
import { LEADER_KITS } from './enemies/leaders.js';
|
||||
import { hypot } from '../detmath.js';
|
||||
|
||||
export {
|
||||
EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles,
|
||||
@@ -312,7 +313,7 @@ function chaliceShare(b, c) {
|
||||
for (const o of b.enemies) {
|
||||
if (!o.alive || o === t || o.defId !== 'enemy_1430_lrrook' || !o.mem.ab) continue;
|
||||
const a = o.mem.ab.list[0];
|
||||
if (!a || !(a.share > 0) || Math.hypot(o.x - t.x, o.y - t.y) > a.r) continue;
|
||||
if (!a || !(a.share > 0) || hypot(o.x - t.x, o.y - t.y) > a.r) continue;
|
||||
const part = c.dmg.amount * a.share;
|
||||
c.dmg.amount -= part;
|
||||
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HUSK_REBIRTH, BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, DUCK_STEALTH_RESTORE, nthOf, stOf, safe, num, T, elem, hurt,
|
||||
areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff,
|
||||
} from './helpers.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// archetypes
|
||||
@@ -105,7 +106,7 @@ function pathKeysAhead(e) {
|
||||
if (!R || !Array.isArray(R.pts)) return keys;
|
||||
let x = e.x, y = e.y;
|
||||
for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) {
|
||||
const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4));
|
||||
const p = R.pts[i], n = Math.max(1, Math.ceil(hypot(p.x - x, p.y - y) * 4));
|
||||
for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n));
|
||||
x = p.x; y = p.y;
|
||||
}
|
||||
@@ -405,7 +406,7 @@ function unbalanced(onMove) {
|
||||
a.px = e.x; a.py = e.y; a.hid = e.hidden;
|
||||
if (px == null || hid || e.hidden) return;
|
||||
const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3;
|
||||
const extra = Math.hypot(e.x - px, e.y - py) - own;
|
||||
const extra = hypot(e.x - px, e.y - py) - own;
|
||||
a.lx = px; a.ly = py; // where the move started (direction for onMove)
|
||||
if (extra > 0.05) onMove(b, e, a, extra);
|
||||
},
|
||||
@@ -605,7 +606,7 @@ export function blinkForward(b, e, dist) {
|
||||
let i = R.ptIdx;
|
||||
while (left > 1e-9 && i < R.pts.length) {
|
||||
const p = R.pts[i];
|
||||
const d = Math.hypot(p.x - e.x, p.y - e.y);
|
||||
const d = hypot(p.x - e.x, p.y - e.y);
|
||||
if (d <= left) { e.x = p.x; e.y = p.y; left -= d; i++; } else { stepToward(e, p.x, p.y, left); left = 0; }
|
||||
}
|
||||
} else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stepToward, setForm, expose,
|
||||
} from './helpers.js';
|
||||
import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
@@ -62,7 +63,7 @@ function kitBlackCloud(ab, e) {
|
||||
const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o));
|
||||
return [
|
||||
skill(k, (b, e2) => {
|
||||
const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY);
|
||||
const list = prey(b, e2).sort((p, q) => hypot(p.x - e2.x, p.y - e2.y) - hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY);
|
||||
if (!list.length) return;
|
||||
b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' });
|
||||
@@ -250,12 +251,12 @@ export const FLY_KITS = Object.freeze({
|
||||
tick(b, e, a, dt) {
|
||||
if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); }
|
||||
if (!a.t) {
|
||||
const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y));
|
||||
const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y));
|
||||
if (!l.length) return;
|
||||
a.t = l[0];
|
||||
b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } });
|
||||
}
|
||||
if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && Math.hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return;
|
||||
if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return;
|
||||
hurt(b, e, a.t, e.s.atk, 'phys');
|
||||
b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' });
|
||||
b.kill(e, null);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ELEMENT } from '../../constants.js';
|
||||
import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js';
|
||||
import { mitigate } from '../../damage.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
@@ -295,7 +296,7 @@ export function spawnChildren(b, parent, key, n, opts = {}) {
|
||||
/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */
|
||||
export function stepToward(u, tx, ty, dist) {
|
||||
const dx = tx - u.x, dy = ty - u.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = hypot(dx, dy);
|
||||
if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; }
|
||||
u.x += (dx / d) * dist;
|
||||
u.y += (dy / d) * dist;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { canTargetAlly, enemyStealthed } from '../../targeting.js';
|
||||
import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js';
|
||||
import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
@@ -20,8 +21,8 @@ const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4;
|
||||
|
||||
function kitCrossbow(ab) {
|
||||
const s = ab.sk.CrossAttack;
|
||||
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
|
||||
const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0];
|
||||
const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH);
|
||||
const nearest = (b, e, l) => l.sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y))[0];
|
||||
// PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%
|
||||
// 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still
|
||||
// in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release
|
||||
@@ -108,7 +109,7 @@ function kitShadowBlade(ab) {
|
||||
tick(b, e) {
|
||||
const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId));
|
||||
const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1;
|
||||
if (partner && Math.hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
|
||||
if (partner && hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat });
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff,
|
||||
freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward,
|
||||
} from './archetypes.js';
|
||||
import { hypot, powi } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
@@ -312,7 +313,7 @@ function kitLeaderMisc(key, ab, e) {
|
||||
const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u));
|
||||
if (!nx) break;
|
||||
hit.add(nx);
|
||||
hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts');
|
||||
hurt(b, e2, nx, e2.s.atk * powi(fall, k), 'arts');
|
||||
if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 });
|
||||
prev = nx;
|
||||
}
|
||||
@@ -323,7 +324,7 @@ function kitLeaderMisc(key, ab, e) {
|
||||
skill(cb, (b, e2) => {
|
||||
const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3;
|
||||
const near = (x, y, r, seen) => b.enemiesInRadius(x, y, r).filter((o) => o !== e2 && !seen.has(o))
|
||||
.sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0];
|
||||
.sort((p, q) => hypot(p.x - x, p.y - y) - hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0];
|
||||
const seen = new Set();
|
||||
let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen);
|
||||
while (cur && seen.size < max) {
|
||||
@@ -357,7 +358,7 @@ function kitLeaderMisc(key, ab, e) {
|
||||
function segDist(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
|
||||
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
return hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
|
||||
/** A second normal hit on the same target ("二连击"): same damage type, no further on-hit procs. */
|
||||
@@ -454,7 +455,7 @@ function kitWarden(ab) {
|
||||
function kitXi(ab) {
|
||||
const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn;
|
||||
const P = { form2: false };
|
||||
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
|
||||
const byDist = (b, e) => allTargets(b, e).sort((p, q) => hypot(p.x - e.x, p.y - e.y) - hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
|
||||
const crossAt = (b, e, t) => {
|
||||
const r0 = t.tileR, c0 = t.tileC;
|
||||
b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id });
|
||||
@@ -783,7 +784,7 @@ export const LEADER_KITS = Object.freeze({
|
||||
if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null });
|
||||
})],
|
||||
enemy_10138_xdsnow: (ab) => [unbalanced((b, e, a) => { // 雪孩子 · pushed / pulled into high ground ⇒ hitWall.value damage
|
||||
const dx = e.x - a.lx, dy = e.y - a.ly, d = Math.hypot(dx, dy);
|
||||
const dx = e.x - a.lx, dy = e.y - a.ly, d = hypot(dx, dy);
|
||||
if (!(d > 0)) return;
|
||||
const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6);
|
||||
if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float,
|
||||
reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3,
|
||||
} from './archetypes.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// constants (numbers that exist nowhere in the data)
|
||||
@@ -245,7 +246,7 @@ export const SPECIAL_KITS = Object.freeze({
|
||||
enemy_1183_mlasrt: (ab) => [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), nthAttackPower(nthOf(ab.sk.PowerAttack), (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1)], // 无胄盟清扫小队 · erosion; every 4th attack ×1.5
|
||||
enemy_1273_stmgun_2: (ab) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit in range, bombards the highest HP% on its 9 tiles
|
||||
// the allies in its range (the engine's ranged reach: radius + the ally collider)
|
||||
const inRange = (b, e) => allTargets(b, e).filter((a) => Math.hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9);
|
||||
const inRange = (b, e) => allTargets(b, e).filter((a) => hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9);
|
||||
return [skill(ab.sk.Cannon, (b, e) => {
|
||||
const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0];
|
||||
if (!lock) return;
|
||||
@@ -279,7 +280,7 @@ export const SPECIAL_KITS = Object.freeze({
|
||||
spawn(b, e) { watchDeaths(b, e); },
|
||||
otherDeath(c, b, e) {
|
||||
const u = c.unit;
|
||||
if (!u || u === e || c.reason !== 'killed' || Math.hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return;
|
||||
if (!u || u === e || c.reason !== 'killed' || hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return;
|
||||
b.heal(e, e, e.s.maxHp * (T(ab, 'Attack.hp_ratio') ?? 0), { self: true });
|
||||
b.addBuff(e, { key: 'ab:torture', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'Attack.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'Attack.atk') ?? 0 } });
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
|
||||
} from '../support/index.js';
|
||||
import { mitigate, hasHp, periodicDamage, isHpLoss } from '../../damage.js';
|
||||
import { hypot } from '../../detmath.js';
|
||||
|
||||
// =====================================================================================================================
|
||||
// data helpers
|
||||
@@ -465,7 +466,7 @@ const BY_ITEM = {
|
||||
const r = num(p.radius, 3), sc = num(p.damage_scale, 1);
|
||||
S.on('hit', (c) => {
|
||||
if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
|
||||
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
|
||||
if (hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
|
||||
});
|
||||
},
|
||||
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Conventions of the tier-2 kits: ../shared/tier2.js; kit contract and rules: ../README.md.
|
||||
|
||||
import { num, talentBb, traitBb, onHitBy, alliesInGridOf, toggleBuff } from '../shared/tier1.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
export default {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -33,7 +34,7 @@ export default {
|
||||
const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale);
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
const d = hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile,
|
||||
groundTile, enemiesOn,
|
||||
} from '../shared/tier3.js';
|
||||
import { powi } from '../../../detmath.js';
|
||||
|
||||
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */
|
||||
const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]);
|
||||
@@ -267,7 +268,7 @@ export default {
|
||||
battle.on('fatal', (ctx) => {
|
||||
if (ctx.unit !== unit || ctx.prevented) return;
|
||||
const n = unit.mem.saveCount ?? 0;
|
||||
const cost = Math.abs(num(t1.cost, -5)) * Math.pow(num(t1.cost_multi, 2), n);
|
||||
const cost = Math.abs(num(t1.cost, -5)) * powi(num(t1.cost_multi, 2), n);
|
||||
const pl = battle.getPlayer(unit.ownerId);
|
||||
if (!pl || pl.dp + 1e-9 < cost) return;
|
||||
battle.addDp(unit.ownerId, -cost);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
|
||||
import { num, defOf, talentBb, traitBb, onTiles, gridKeys, fx, copyGrid } from '../shared/tier3.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
|
||||
const COLLIDE = 0.6;
|
||||
@@ -33,7 +34,7 @@ export default {
|
||||
// (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official
|
||||
// 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
|
||||
// stopped short of it ⇒ it hit a wall
|
||||
const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
|
||||
const near = hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
|
||||
const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true });
|
||||
const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true });
|
||||
const wall = expect > 0 && moved + 0.05 < expect;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AURA, num, tbb, grid, nationOf, enemiesOnRange, targetsInRange, whileDeployed, reveal, skillActive, isSel, alt,
|
||||
instantKind, withDefaults,
|
||||
} from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/** Is module `id` the active one? (elites; `'none'` / normal chess ⇒ false) */
|
||||
const moduleIs = (def, id) => !!(def?.raw?.module?.active && def.raw.module.id === id);
|
||||
@@ -39,7 +40,7 @@ export default withDefaults({
|
||||
const n = num(bb.charge, 1);
|
||||
const cand = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId)
|
||||
.filter((a) => a !== unit && a.kind === 'op' && nationOf(a) === 'laterano' && a.skill && a.skill.active && a.skill.kind === 'ammo')
|
||||
.sort((a, b) => Math.hypot(a.x - unit.x, a.y - unit.y) - Math.hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id);
|
||||
.sort((a, b) => hypot(a.x - unit.x, a.y - unit.y) - hypot(b.x - unit.x, b.y - unit.y) || a.id - b.id);
|
||||
if (!cand[0] || !(n > 0)) return;
|
||||
cand[0].skill.addAmmo(n);
|
||||
battle.fx('reload', { x: cand[0].x, y: cand[0].y, id: cand[0].id, n });
|
||||
|
||||
@@ -49,7 +49,7 @@ export default withDefaults({
|
||||
unit.mem.sentry = null;
|
||||
unit.mem.inesSentryAt = null;
|
||||
const ax = from.x, ay = from.y, bx = unit.x, by = unit.y;
|
||||
const L2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||||
const L2 = (bx - ax) * (bx - ax) + (by - ay) * (by - ay);
|
||||
const w = num(bb.projectile_range, 1.4);
|
||||
const hits = [];
|
||||
for (const e of battle.aliveEnemies()) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { COLS } from '../../../constants.js';
|
||||
import {
|
||||
num, tbb, grid, targetsInRange, targetsInGrid, toggleBuff, skillActive, withDefaults,
|
||||
} from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/**
|
||||
* Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out
|
||||
@@ -39,7 +40,7 @@ export default withDefaults({
|
||||
for (const k of unit.rangeKeys || []) {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!freeTile(battle, r, c)) continue;
|
||||
const d = Math.hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5);
|
||||
const d = hypot(c - ref.x, r - ref.y) + (battle.grid.groundPassable(r, c) ? 0 : 5);
|
||||
if (d < bd - 1e-9) { bd = d; best = [r, c]; }
|
||||
}
|
||||
if (!best) return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { num, tbb, keySet, skillActive, isSel, alt, instantKind, withDefaults } from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */
|
||||
const AROMA_MARK = 'aroma:bubbled';
|
||||
@@ -71,7 +72,7 @@ export default withDefaults({
|
||||
const lo = num(tb.min_dist, 0), hi = num(tb.max_dist, 4);
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source !== unit || !c.dmg.isAttack) return;
|
||||
const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y);
|
||||
const d = hypot(c.target.x - unit.x, c.target.y - unit.y);
|
||||
c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AURA, num, tbb, moduleBb, grid, batFlat, enemyHasTag, targetsInRange, whileDeployed, pulse, alt, instantKind,
|
||||
pullToFront, withDefaults,
|
||||
} from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
|
||||
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
|
||||
@@ -28,7 +29,7 @@ export default withDefaults({
|
||||
};
|
||||
// module HOK-Y: "向自身拖拽较远的敌人时力度提升一个等级"
|
||||
const farR = num(mb['skill@range_radius'], num(mb['attack@range_radius'], 0)), farF = num(mb['skill@delta_force'], num(mb['attack@delta_force'], 0));
|
||||
const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && Math.hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f);
|
||||
const selfForce = (unit, e, f) => (farR > 0 && farF > 0 && hypot(e.x - unit.x, e.y - unit.y) > farR + 1e-9 ? f + farF : f);
|
||||
const pullSelf = (battle, unit, e, f) => { if (e && e.alive) dragDmg(battle, unit, e, pullToFront(battle, unit, e, selfForce(unit, e, f))); };
|
||||
return {
|
||||
skills: alt(def, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bodyInKeys } from '../../../body.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
import { num, tbb, whileDeployed, toggleBuff, skillActive, isSel, alt, withDefaults } from '../shared/tier4.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i])));
|
||||
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
|
||||
@@ -83,7 +84,7 @@ export default withDefaults({
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source !== unit || !c.dmg.isAttack) return;
|
||||
if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25);
|
||||
if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
|
||||
if (ds > 0) { const d = hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { rotateOffset } from '../../../dir.js';
|
||||
import { AURA_IV, AURA_DUR, num, talent, traitBb, skillGrid, isOp, leaderOf, whileOn } from '../shared/tier5.js';
|
||||
import { hypot, sin, cos } from '../../../detmath.js';
|
||||
|
||||
/** 魔王 T1 微尘: collision radius of one mote (PRTS 备注 0.4); orbit radius / speed come from the talent bb. */
|
||||
const MOTE_HIT_RADIUS = 0.4;
|
||||
@@ -82,11 +83,11 @@ export default {
|
||||
const ang = spd * t + (2 * Math.PI * k) / cnt;
|
||||
// the orbit turns with his direction: angle 0 = straight ahead, π/2 = his left hand
|
||||
const [fr, fc] = unit.fwd, [lr, lc] = rotateOffset(1, 0, unit.dir);
|
||||
const mx = unit.x + orbit * (fc * Math.cos(ang) + lc * Math.sin(ang)), my = unit.y + orbit * (fr * Math.cos(ang) + lr * Math.sin(ang));
|
||||
const mx = unit.x + orbit * (fc * cos(ang) + lc * sin(ang)), my = unit.y + orbit * (fr * cos(ang) + lr * sin(ang));
|
||||
let hit = null, hd = Infinity;
|
||||
for (const a of cands) {
|
||||
if (a.findBuff('cetsyr:mote')) continue;
|
||||
const d = Math.hypot(a.x - mx, a.y - my);
|
||||
const d = hypot(a.x - mx, a.y - my);
|
||||
if (d <= MOTE_HIT_RADIUS + 1e-9 && d < hd) { hd = d; hit = a; }
|
||||
}
|
||||
if (!hit) continue;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AURA_IV, AURA_DUR, RING1, num, on, talent, skillGrid, maxCharges, mods, dist, isOp, selectedId, lazySkills,
|
||||
instantKind, whileOn, permBuff,
|
||||
} from '../shared/tier5.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
/** Straight-road length through each tile (max of the horizontal and vertical runs of ground-passable tiles). */
|
||||
const ROAD_CACHE = new WeakMap();
|
||||
@@ -134,7 +135,7 @@ export default {
|
||||
}
|
||||
const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0;
|
||||
// it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向")
|
||||
const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1;
|
||||
const dx = t.x - unit.x, dy = t.y - unit.y, len = hypot(dx, dy) || 1;
|
||||
// `key`: the fx re-sent every second as the unit drifts and grows is this one zone — the client updates it in
|
||||
// place instead of stacking a new layer each second (render/fx/zones.js; community report of 2026-10-06)
|
||||
const key = `thorn2:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { num, tbb, moduleBb, live, keyOf, enemiesIn, isTok, instantKind, AROUND8, aura } from '../shared/tier6.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心
|
||||
@@ -217,7 +218,7 @@ function lureGoal(battle, unit, e) {
|
||||
for (const [dr, dc] of AROUND8) {
|
||||
const r = unit.tileR + dr, c = unit.tileC + dc;
|
||||
if (!battle.grid.groundPassable(r, c)) continue;
|
||||
const d = Math.hypot(e.x - c, e.y - r);
|
||||
const d = hypot(e.x - c, e.y - r);
|
||||
if (d < bd - 1e-9) { bd = d; goal = [r, c]; }
|
||||
}
|
||||
return goal;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||
import { CHAIN_RADIUS } from '../../../constants.js';
|
||||
import { bodyDist, bodyInKeys } from '../../../body.js';
|
||||
import { num, bv, tbb, live, ANY, enemiesIn, batOf, N4, aura } from '../shared/tier6.js';
|
||||
import { powi } from '../../../detmath.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒
|
||||
@@ -25,7 +26,7 @@ function pasngr(bb, chess, def) {
|
||||
for (let i = 0; i < count && prev; i++) {
|
||||
hit.add(prev.id);
|
||||
battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id });
|
||||
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
|
||||
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * powi(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
|
||||
if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit });
|
||||
let best = null, bd = Infinity;
|
||||
for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
|
||||
import { rotateOffset } from '../../../dir.js';
|
||||
import { bodyDist } from '../../../body.js';
|
||||
import { num, tbb, hasBond, ANY, selectedSkill, skillGridOf, WHOLE_FIELD, bstate, aura } from '../shared/tier6.js';
|
||||
import { hypot, sin, cos, atan2 } from '../../../detmath.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸
|
||||
@@ -39,10 +40,10 @@ function whitw2(bb, chess, def) {
|
||||
// ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row)
|
||||
// plane, counter-clockwise)
|
||||
const releaseDrones = (unit, k) => {
|
||||
const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]);
|
||||
const a0 = atan2(unit.fwd[0], unit.fwd[1]);
|
||||
for (let i = 0; i < k; i++) {
|
||||
const a = a0 + (2 * Math.PI * i) / k;
|
||||
unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null });
|
||||
unit.mem.drones.push({ x: unit.x, y: unit.y, hx: cos(a), hy: sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null });
|
||||
}
|
||||
};
|
||||
// speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration)
|
||||
@@ -56,7 +57,7 @@ function whitw2(bb, chess, def) {
|
||||
let best = null, bd = Infinity, bh = Infinity;
|
||||
for (const e of battle.enemies) {
|
||||
if (!ok(e)) continue;
|
||||
const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y);
|
||||
const de = hypot(e.x - d.x, e.y - d.y), dh = hypot(e.x - unit.x, e.y - unit.y);
|
||||
if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; }
|
||||
}
|
||||
return best;
|
||||
@@ -65,9 +66,9 @@ function whitw2(bb, chess, def) {
|
||||
const circle = (d, dt) => {
|
||||
const { r, v } = WHITW2_ORBIT;
|
||||
if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r };
|
||||
const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt;
|
||||
d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a);
|
||||
d.hx = -Math.sin(a); d.hy = Math.cos(a);
|
||||
const a = atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt;
|
||||
d.x = d.orbit.cx + r * cos(a); d.y = d.orbit.cy + r * sin(a);
|
||||
d.hx = -sin(a); d.hy = cos(a);
|
||||
};
|
||||
// one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack
|
||||
// clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit
|
||||
@@ -103,7 +104,7 @@ function whitw2(bb, chess, def) {
|
||||
if (d.phase === 'chase') {
|
||||
const s = accelerate(d, WHITW2_CHASE, dt);
|
||||
if (bodyDist(t, d.x, d.y) > s + 1e-9) {
|
||||
const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy);
|
||||
const dx = t.x - d.x, dy = t.y - d.y, L = hypot(dx, dy);
|
||||
if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; }
|
||||
d.x += d.hx * s; d.y += d.hy * s;
|
||||
return;
|
||||
|
||||
@@ -63,6 +63,7 @@ import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { RESIST_STATUSES } from '../../../buffs.js';
|
||||
import { TICK } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_aglna2_1';
|
||||
const S2 = 'skchr_aglna2_2';
|
||||
@@ -342,7 +343,7 @@ export default {
|
||||
if (!st || !skillOn(unit, S3) || !up(unit) || st.flight || !unit.canAct || unit.blocking.length) return;
|
||||
const tgt = moveTarget(battle, unit);
|
||||
if (!tgt) return;
|
||||
const dur = Math.hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED;
|
||||
const dur = hypot(tgt.r - unit.tileR, tgt.c - unit.tileC) / MOVE_SPEED;
|
||||
const act = unit.skill.activations;
|
||||
st.flight = tgt;
|
||||
battle.releaseBlocked(unit);
|
||||
|
||||
@@ -62,6 +62,7 @@ import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp, isHpLoss } from '../../../damage.js';
|
||||
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_cqbw_1';
|
||||
const S2 = 'skchr_cqbw_2';
|
||||
@@ -133,7 +134,7 @@ function mineTile(battle, unit) {
|
||||
let best = [], bd = Infinity;
|
||||
for (const t of tiles) {
|
||||
let d = Infinity;
|
||||
for (const e of foes) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
|
||||
for (const e of foes) d = Math.min(d, hypot(e.x - t[1], e.y - t[0]));
|
||||
if (d < bd - 1e-9) { bd = d; best = [t]; } else if (Math.abs(d - bd) <= 1e-9) best.push(t);
|
||||
}
|
||||
return best.length === 1 ? best[0] : battle.rng.pick(best);
|
||||
|
||||
@@ -67,6 +67,7 @@ import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } fr
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_ebnhlz_1';
|
||||
const S2 = 'skchr_ebnhlz_2';
|
||||
@@ -116,7 +117,7 @@ function remnantTiles(battle, unit) {
|
||||
const rank = new Map();
|
||||
foes.forEach((e, i) => { const k = Math.round(e.y) * COLS + Math.round(e.x); if (!rank.has(k)) rank.set(k, i); });
|
||||
battle.rng.shuffle(tiles); // the random order of the last group, and of equal distances [ASSUMED]
|
||||
const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, Math.hypot(e.x - c, e.y - r)); return d; };
|
||||
const dist = (r, c) => { let d = Infinity; for (const e of foes) d = Math.min(d, hypot(e.x - c, e.y - r)); return d; };
|
||||
const keyed = tiles.map(([r, c], i) => ({ r, c, i, on: rank.get(r * COLS + c) ?? Infinity, d: dist(r, c) }));
|
||||
keyed.sort((a, b) => a.on - b.on || a.d - b.d || a.i - b.i);
|
||||
return keyed.map((x) => [x.r, x.c]);
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
import { num, talentBb, moduleBb, moduleOn, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js';
|
||||
import { toLocal } from '../../../dir.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_haak_1';
|
||||
const S2 = 'skchr_haak_2';
|
||||
@@ -80,7 +81,7 @@ export function frontAlly(battle, unit, heal = false) {
|
||||
if (heal && (a.s.flags.noHeal || a.s.flags.healFree || a.profile?.noHeal)) continue;
|
||||
const dr = a.tileR - unit.tileR, dc = a.tileC - unit.tileC;
|
||||
const [lr, lc] = toLocal(dr, dc, unit.dir);
|
||||
const k = [lr === 0 && lc > 0 ? 0 : 1, Math.hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id];
|
||||
const k = [lr === 0 && lc > 0 ? 0 : 1, hypot(dr, dc), -(a.s.taunt || 0), -(a.aggroSeq || a.deploySeq || 0), a.id];
|
||||
let less = !bk;
|
||||
for (let i = 0; !less && i < k.length; i++) {
|
||||
if (k[i] === bk[i]) continue;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
import { num, up, traitBb, talentBb, skillRec, batMod, giveSp, protectMods } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_haruka_1';
|
||||
const S2 = 'skchr_haruka_2';
|
||||
@@ -217,7 +218,7 @@ export default {
|
||||
if (c.source !== unit || c.opts?.regen || !skillOn(unit, S2) || !(c.amount > 0) || !(scale > 0) || !n) return;
|
||||
const t = c.target;
|
||||
const foes = battle.foesInRadius(t.x, t.y, r);
|
||||
foes.sort((x, y) => Math.hypot(x.x - t.x, x.y - t.y) - Math.hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq);
|
||||
foes.sort((x, y) => hypot(x.x - t.x, x.y - t.y) - hypot(y.x - t.x, y.y - t.y) || x.spawnSeq - y.spawnSeq);
|
||||
const amount = c.amount * scale;
|
||||
for (const e of foes.slice(0, n)) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', 'haruka:s2'] });
|
||||
if (foes.length) battle.fx('aoe', { x: t.x, y: t.y, radius: r, id: unit.id, skill: 'haruka:s2' });
|
||||
|
||||
@@ -57,6 +57,7 @@ import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInRadius } from '../../../body.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { sin, cos, atan2 } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_hsgma2_1';
|
||||
const S2 = 'skchr_hsgma2_2';
|
||||
@@ -98,7 +99,7 @@ export default {
|
||||
/** S2: the shield's centre after `turned` degrees (row 0 is the bottom row: counter-clockwise = a growing angle). */
|
||||
const shieldAt = (unit, sh) => {
|
||||
const a = sh.a0 + (sh.turned * Math.PI) / 180;
|
||||
return { x: unit.x + SHIELD_START * Math.cos(a), y: unit.y + SHIELD_START * Math.sin(a) };
|
||||
return { x: unit.x + SHIELD_START * cos(a), y: unit.y + SHIELD_START * sin(a) };
|
||||
};
|
||||
const touching = (battle, unit, p) => battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND) && bodyInRadius(e, p.x, p.y, SHIELD_RADIUS));
|
||||
/** S2: one shield pulse — 停顿, the arts hit, and her heal of heal_ratio × the HP it removed (ignoring her 禁疗). */
|
||||
@@ -128,7 +129,7 @@ export default {
|
||||
if (!('hits' in s2Attack)) return;
|
||||
plainS2(); // the throw was this attack: the shield flies from now on
|
||||
const [fr, fc] = unit.fwd;
|
||||
unit.mem.hsShield = { a0: Math.atan2(fr, fc), turned: 0, acc: 0 };
|
||||
unit.mem.hsShield = { a0: atan2(fr, fc), turned: 0, acc: 0 };
|
||||
battle.fx('zone', { x: unit.x, y: unit.y, radius: SHIELD_START + SHIELD_RADIUS, dur: SHIELD_TURN / SHIELD_SPEED, id: unit.id, skill: 'hsgma2:shield' });
|
||||
shieldPulse(battle, unit, shieldAt(unit, unit.mem.hsShield));
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_ifrit_1';
|
||||
const S2 = 'skchr_ifrit_2';
|
||||
@@ -197,7 +198,7 @@ export default {
|
||||
battle.on('hit', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
|
||||
const d = Math.hypot(e.x - unit.x, e.y - unit.y);
|
||||
const d = hypot(e.x - unit.x, e.y - unit.y);
|
||||
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { num, talentBb, traitBb, moduleOn, skillRec, batMod, up, giveSp, installReveal } from '../shared/tier1.js';
|
||||
import { offsetTile, dirFromDelta, dirVec } from '../../../dir.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_jesca2_1';
|
||||
const S2 = 'skchr_jesca2_2';
|
||||
@@ -182,7 +183,7 @@ function fireBomb(battle, unit, b3) {
|
||||
};
|
||||
(unit.mem.jescaShells ??= []).push(bomb);
|
||||
unit.atkCd = unit.s.interval; // the shell is her attack: the timer restarts [ASSUMED]
|
||||
battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: Math.hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED });
|
||||
battle.fx('bombardShell', { x: bomb.tx, y: bomb.ty, id: unit.id, r: bomb.radius, t: hypot(bomb.tx - bomb.x, bomb.ty - bomb.y) / BOMB_SPEED });
|
||||
}
|
||||
|
||||
/** Move the shells one tick; a shell touching an enemy or reaching its end point explodes. */
|
||||
@@ -192,7 +193,7 @@ function flyShells(battle, unit, dt) {
|
||||
const keep = [];
|
||||
for (const s of list) {
|
||||
const dx = s.tx - s.x, dy = s.ty - s.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = hypot(dx, dy);
|
||||
const step = BOMB_SPEED * dt;
|
||||
let hit = battle.foesInRadius(s.x, s.y, BOMB_HIT_RADIUS).length > 0;
|
||||
if (!hit) {
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
import { num, talentBb, skillRec, batMod, up } from '../shared/tier1.js';
|
||||
import { releaseSkillSummon } from '../../tokens.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_kalts2_1';
|
||||
const S2 = 'skchr_kalts2_2';
|
||||
@@ -235,7 +236,7 @@ function startTransport(battle, unit, anchor) {
|
||||
}, { owner: unit });
|
||||
}
|
||||
const from = { x: unit.x, y: unit.y }, to = { x: anchor.x, y: anchor.y };
|
||||
const T = Math.hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED;
|
||||
const T = hypot(to.x - from.x, to.y - from.y) / TRANSPORT_SPEED;
|
||||
const f = { t0: battle.time, T, from, to, hit: new Set(), anchor, r: anchor.tileR, c: anchor.tileC, dir: anchor.dir };
|
||||
unit.mem.k2flight = f;
|
||||
battle.releaseBlocked(unit);
|
||||
|
||||
@@ -46,6 +46,7 @@ import { hasHp } from '../../../damage.js';
|
||||
import { canTargetEnemy, enemyStealthed } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { COLS, PALSY_MAX } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_mantra_1';
|
||||
const S2 = 'skchr_mantra_2';
|
||||
@@ -91,7 +92,7 @@ function nearestFoe(battle, unit, x, y, r, skip) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of battle.enemiesInRadius(x, y, r)) {
|
||||
if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
const d = hypot(e.x - x, e.y - y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||||
}
|
||||
return best;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { num, skillRec, batMod, up } from '../shared/tier1.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { dirVec } from '../../../dir.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_mcnist_1';
|
||||
const S2 = 'skchr_mcnist_2';
|
||||
@@ -197,7 +198,7 @@ function grafKit(owner, { life, t2, b2, b3, aura }) {
|
||||
function chargeGraf(battle, mcn, t, b3) {
|
||||
if (!up(t)) return;
|
||||
const { stop, land } = chargeEnd(battle, t);
|
||||
const dist = Math.hypot(stop[0] - t.tileR, stop[1] - t.tileC);
|
||||
const dist = hypot(stop[0] - t.tileR, stop[1] - t.tileC);
|
||||
t.mem.charging = true;
|
||||
battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] });
|
||||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_narant_1';
|
||||
const S2 = 'skchr_narant_2';
|
||||
@@ -138,7 +139,7 @@ export default {
|
||||
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; }
|
||||
const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile));
|
||||
if (!cands.length) { ctx.comeBack(x, y); return; }
|
||||
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
|
||||
const d2 = (e) => (e.x - x) * (e.x - x) + (e.y - y) * (e.y - y);
|
||||
cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq);
|
||||
const tgt = cands[0];
|
||||
left--;
|
||||
@@ -158,7 +159,7 @@ export default {
|
||||
const dash = (ctx) => {
|
||||
const { battle, unit } = ctx;
|
||||
let dx = ctx.x - unit.x, dy = ctx.y - unit.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
const len = hypot(dx, dy);
|
||||
if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; }
|
||||
const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5));
|
||||
battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true,
|
||||
|
||||
@@ -68,6 +68,7 @@ import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp, burstLocked } from '../../../damage.js';
|
||||
import { MIN_DAMAGE_RATIO } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_phatm2_1';
|
||||
const S2 = 'skchr_phatm2_2';
|
||||
@@ -134,7 +135,7 @@ function callKit(t, owner, b2) {
|
||||
const keys = keysAt(grid, tok);
|
||||
const cands = battle.enemiesInKeys([...keys], tok, ANY).filter((e) => !lured.includes(e) && !e.s.flags.attract
|
||||
&& (e.motion === 'FLY' || !!battle.grid.waypoints(Math.round(e.y), Math.round(e.x), tok.tileR, tok.tileC)));
|
||||
cands.sort((a, b) => (isElite(b) - isElite(a)) || (Math.hypot(a.x - tok.x, a.y - tok.y) - Math.hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq);
|
||||
cands.sort((a, b) => (isElite(b) - isElite(a)) || (hypot(a.x - tok.x, a.y - tok.y) - hypot(b.x - tok.x, b.y - tok.y)) || a.spawnSeq - b.spawnSeq);
|
||||
for (const e of cands) {
|
||||
if (lured.length >= cap) break;
|
||||
if (battle.applyStatus(e, 'attract', { duration: Math.max(0.05, endAt - battle.time), source: tok, point: [tok.tileR, tok.tileC] })) {
|
||||
@@ -146,7 +147,7 @@ function callKit(t, owner, b2) {
|
||||
// the first lured enemy within `arrive` of it: it leaves
|
||||
battle.on('tick', () => {
|
||||
if (!up(tok)) return;
|
||||
if (lured.some((e) => e.alive && mine(e) && Math.hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true });
|
||||
if (lured.some((e) => e.alive && mine(e) && hypot(e.x - tok.x, e.y - tok.y) <= arrive + 1e-9)) battle.retreat(tok, { reason: 'expired', permanent: true });
|
||||
}, { owner: tok });
|
||||
// leaving (any reason): 停顿 + the damage over time on every selectable enemy of its x-1; the 诱导 ends
|
||||
battle.on('death', (c) => {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js';
|
||||
import { toLocal } from '../../../dir.js';
|
||||
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_phenxi_1';
|
||||
const S2 = 'skchr_phenxi_2';
|
||||
@@ -136,7 +137,7 @@ function s3Shot(battle, unit, b3) {
|
||||
battle.addProjectile({ from: unit, to: { x, y }, speed: S3_SPEED, visual: 'bomb', source: unit, onHit: () => {
|
||||
battle.fx('aoe', { x, y, radius: S3_RADIUS, id: unit.id, skill: 'phenxi:s3' });
|
||||
for (const e of battle.foesInRadius(x, y, S3_RADIUS, true)) {
|
||||
const sc = Math.hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo;
|
||||
const sc = hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * sc * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'phenxi:s3'] });
|
||||
}
|
||||
} });
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skcom_atk_up[3]';
|
||||
const S2 = 'skchr_poca_2';
|
||||
@@ -161,7 +162,7 @@ export default {
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
const d = hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { acquireTargets } from '../../../ai.js';
|
||||
import { aggregateMods } from '../../../buffs.js';
|
||||
import { dirVec } from '../../../dir.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_radian_1';
|
||||
const S2 = 'skchr_radian_2';
|
||||
@@ -160,7 +161,7 @@ function flyBullets(battle, t, dt) {
|
||||
const hit = () => {
|
||||
const near = battle.foesInRadius(b.x, b.y, BULLET_RADIUS).filter((e) => e.alive && !e.isFlying);
|
||||
if (!near.length) return false;
|
||||
near.sort((p, q) => Math.hypot(p.x - b.x, p.y - b.y) - Math.hypot(q.x - b.x, q.y - b.y) || p.spawnSeq - q.spawnSeq);
|
||||
near.sort((p, q) => hypot(p.x - b.x, p.y - b.y) - hypot(q.x - b.x, q.y - b.y) || p.spawnSeq - q.spawnSeq);
|
||||
battle.dealDamage(t, near[0], { amount: b.atk, type: 'phys', isAttack: true, tags: [TAG_BULLET] });
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -63,6 +63,7 @@ import { sortEnemyTargets } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { PUSH_DIRECTIONAL_MIN_DIST } from '../../../constants.js';
|
||||
import { startCountdown } from '../../tokens.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_ray_1';
|
||||
const S2 = 'skchr_ray_2';
|
||||
@@ -159,7 +160,7 @@ export default {
|
||||
if (!target.alive) { unit.mem.rayLoadExtra = num(unit.mem.rayLoadExtra) + Math.max(0, Math.floor(num(b1.cnt, 1))); return; }
|
||||
const force = num(b1.force, 1);
|
||||
const [fr, fc] = unit.fwd;
|
||||
const vx = target.x - unit.x, vy = target.y - unit.y, d = Math.hypot(vx, vy);
|
||||
const vx = target.x - unit.x, vy = target.y - unit.y, d = hypot(vx, vy);
|
||||
if (d >= PUSH_DIRECTIONAL_MIN_DIST && vx * fc + vy * fr >= d * Math.SQRT1_2) battle.push(target, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
|
||||
else battle.push(target, force - 1, { from: unit });
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, up } from '../sha
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { offsetTile } from '../../../dir.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skcom_assist_cost[3]';
|
||||
const S2 = 'skchr_sleach_2';
|
||||
@@ -150,7 +151,7 @@ function s3Tile(battle, unit, grid) {
|
||||
for (const k of keys) {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!ground(r, c) || battle.unitAt(r, c)) continue;
|
||||
const d = Math.hypot(c - top.x, r - top.y);
|
||||
const d = hypot(c - top.x, r - top.y);
|
||||
if (d < bd - 1e-9) { bd = d; best = [r, c]; }
|
||||
}
|
||||
return best ?? [unit.tileR, unit.tileC];
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { ROWS, COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skcom_quickattack[3]';
|
||||
const S2 = 'skchr_typhon_2';
|
||||
@@ -204,7 +205,7 @@ export default {
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
const d = hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, toggleBuff } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_veen_1';
|
||||
const S2 = 'skchr_veen_2';
|
||||
@@ -115,7 +116,7 @@ export default {
|
||||
if (!pool.length) pool = near;
|
||||
let next = null, bd = Infinity;
|
||||
for (const e of pool) {
|
||||
const d = Math.hypot(e.x - cur.x, e.y - cur.y);
|
||||
const d = hypot(e.x - cur.x, e.y - cur.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; }
|
||||
}
|
||||
if (!next) return; // nobody within reach: the bounces left are lost
|
||||
|
||||
@@ -49,6 +49,7 @@ import { num, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { startCountdown } from '../../tokens.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_weedy_1';
|
||||
const S2 = 'skchr_weedy_2';
|
||||
@@ -92,7 +93,7 @@ export function rupture(battle, src, e, { duration, perTile, interval }) {
|
||||
return;
|
||||
}
|
||||
const st = { x: e.x, y: e.y, moved: 0, acc: 0 };
|
||||
const step = (u) => { st.moved += Math.hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; };
|
||||
const step = (u) => { st.moved += hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; };
|
||||
const flush = (u) => {
|
||||
const m = st.moved;
|
||||
st.moved = 0;
|
||||
@@ -121,7 +122,7 @@ export function liquidNitrogen(battle, { shooter, src, atk, sbb, grid, force, pi
|
||||
const keys = absoluteRangeKeys(grid ?? GRID_4_1, shooter.tileR, shooter.tileC, shooter.dir, 0);
|
||||
const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true });
|
||||
if (!cands.length) return false;
|
||||
const key = pick === 'nearest' ? (e) => Math.hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e);
|
||||
const key = pick === 'nearest' ? (e) => hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e);
|
||||
let target = null, best = Infinity;
|
||||
for (const e of cands) {
|
||||
const v = key(e);
|
||||
|
||||
@@ -61,6 +61,7 @@ import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
const S1 = 'skchr_wisdel_1';
|
||||
const S2 = 'skchr_wisdel_2';
|
||||
@@ -138,7 +139,7 @@ function shadowTile(battle, host) {
|
||||
for (const k of host.rangeKeys ?? []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
const d = Math.hypot(r - host.tileR, c - host.tileC);
|
||||
const d = hypot(r - host.tileR, c - host.tileC);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && (r < best[0] || (r === best[0] && c < best[1])))) { bd = d; best = [r, c]; }
|
||||
}
|
||||
return best;
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
|
||||
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
|
||||
@@ -70,7 +71,7 @@ const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?
|
||||
const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; };
|
||||
/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */
|
||||
const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; };
|
||||
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const dist = (a, b) => hypot(a.x - b.x, a.y - b.y);
|
||||
const nationOf = (u) => u?.def?.raw?.nationId ?? null;
|
||||
const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u)));
|
||||
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId);
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { hypot } from '../../../detmath.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
@@ -237,7 +238,7 @@ function bestTile(battle, tiles) {
|
||||
let best = tiles[0], bd = Infinity;
|
||||
for (const t of tiles) {
|
||||
let d = Infinity;
|
||||
for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
|
||||
for (const e of en) d = Math.min(d, hypot(e.x - t[1], e.y - t[0]));
|
||||
if (d < bd - 1e-9) { bd = d; best = t; }
|
||||
}
|
||||
return best;
|
||||
|
||||
@@ -92,6 +92,7 @@ import { genericKit } from './generic.js';
|
||||
import { bardRegen } from '../professions.js';
|
||||
import { normDir, localOrder } from '../dir.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { hypot } from '../detmath.js';
|
||||
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
||||
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||
@@ -451,7 +452,7 @@ export function findSummonTile(battle, owner, placement = 'melee', opts = {}) {
|
||||
let cover = 0;
|
||||
if (placement === 'enemy' || placement === 'melee') cover = countNear(enemies, r, c, 1);
|
||||
else if (placement === 'ally') cover = countNear(injured, r, c, 1);
|
||||
const d = placement === 'ally' ? Math.hypot(c - owner.x, r - owner.y) : Math.hypot(c - focus.x, r - focus.y);
|
||||
const d = placement === 'ally' ? hypot(c - owner.x, r - owner.y) : hypot(c - focus.x, r - focus.y);
|
||||
// last: the tile's offset in the owner's facing-RIGHT frame (for a RIGHT owner = the tile key order), so equal
|
||||
// candidates resolve the same way whichever direction the owner faces
|
||||
const [lr, lc] = localOrder(r - owner.tileR, c - owner.tileC, owner.dir);
|
||||
@@ -1236,7 +1237,7 @@ function yanyouKit(bb, raw) {
|
||||
const keys = [];
|
||||
const r0 = Math.floor(u.y - radius), r1 = Math.ceil(u.y + radius), c0 = Math.floor(u.x - radius), c1 = Math.ceil(u.x + radius);
|
||||
for (let r = Math.max(0, r0); r <= Math.min(ROWS - 1, r1); r++) {
|
||||
for (let c = Math.max(0, c0); c <= Math.min(COLS - 1, c1); c++) if (Math.hypot(c - u.x, r - u.y) <= radius + 1e-9) keys.push(r * COLS + c);
|
||||
for (let c = Math.max(0, c0); c <= Math.min(COLS - 1, c1); c++) if (hypot(c - u.x, r - u.y) <= radius + 1e-9) keys.push(r * COLS + c);
|
||||
}
|
||||
u.rangeKeys = keys;
|
||||
u.rangeKeySet = new Set(keys);
|
||||
@@ -1299,7 +1300,7 @@ function yanyouKit(bb, raw) {
|
||||
const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
|
||||
if (tgt) {
|
||||
const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = hypot(dx, dy);
|
||||
if (d > YANYOU_STOP + 1e-6) {
|
||||
const step = Math.min(d - YANYOU_STOP, speed * dt);
|
||||
const R = battle.rect;
|
||||
@@ -1351,7 +1352,7 @@ function airTile(battle, playerId, taken) {
|
||||
if (taken.has(r * COLS + c) || held.has(r * COLS + c) || !tileFree(battle, r, c)) continue;
|
||||
const t = battle.grid.tile(r, c);
|
||||
const cls = t.build === 'NONE' && t.pass !== 'ALL' ? 0 : t.build === 'NONE' ? 1 : 2;
|
||||
const s = [cls, Math.hypot(r - cr, c - cc), r * COLS + c];
|
||||
const s = [cls, hypot(r - cr, c - cc), r * COLS + c];
|
||||
let less = !bs;
|
||||
if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
|
||||
if (less) { best = [r, c]; bs = s; }
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// server/sim/detmath.js — engine-independent math for the sim. Every function here gives the same bits on every
|
||||
// conforming JavaScript engine.
|
||||
//
|
||||
// ECMA-262 lets Math.hypot, sin, cos, atan2, pow (and **) return "implementation-approximated" values, and the engines
|
||||
// differ in the last bits: V8's hypot is not the one of SpiderMonkey (fdlibm) or JavaScriptCore (sqrt(x*x + y*y));
|
||||
// Chrome 154's sin / cos / atan2 are correctly rounded while Node 24–26's are fdlibm's; JavaScriptCore computes an
|
||||
// integer power by repeated squaring. + − × ÷ and Math.sqrt are correctly rounded IEEE 754 operations, and the language
|
||||
// fuses nothing (no FMA, no extended precision), so code built only from them is reproducible bit for bit. That is
|
||||
// what these functions are. The sim must not call the approximated Math functions (eslint.config.js, and
|
||||
// test/sim/detmath.test.js scans server/sim/).
|
||||
//
|
||||
// hypot(x, y) √(x² + y²) as Math.sqrt(x*x + y*y), rescaled by exact powers of two outside 2^±500
|
||||
// powi(x, n) xⁿ for an integer n: square-and-multiply in double-double, one final rounding
|
||||
// sin(x), cos(x) fdlibm 5.3 (__kernel_sin / __kernel_cos / __ieee754_rem_pio2)
|
||||
// atan2(y, x) fdlibm (FreeBSD msun e_atan2.c / s_atan.c)
|
||||
//
|
||||
// sin / cos / atan2 are the algorithms V8 runs in Node, so they return what the server always computed (its golden
|
||||
// digests stay); hypot and powi are at least as accurate as V8's. ±∞ / NaN arguments take the exactly specified
|
||||
// results of the Math functions.
|
||||
|
||||
const F64 = new Float64Array(1);
|
||||
const I32 = new Int32Array(F64.buffer);
|
||||
const HW = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1 ? 1 : 0; // index of the high word
|
||||
const LW = 1 - HW;
|
||||
/** @param {number} x */
|
||||
const hi = (x) => { F64[0] = x; return I32[HW]; };
|
||||
/** @param {number} x */
|
||||
const lo = (x) => { F64[0] = x; return I32[LW] >>> 0; };
|
||||
/** @param {number} h @param {number} l */
|
||||
const fromWords = (h, l) => { I32[HW] = h; I32[LW] = l; return F64[0]; };
|
||||
/** exact 2^e for a normal e @param {number} e */
|
||||
const pow2 = (e) => fromWords((e + 1023) << 20, 0);
|
||||
const P500 = pow2(500), PM500 = pow2(-500), P600 = pow2(600), PM600 = pow2(-600);
|
||||
|
||||
/**
|
||||
* √(x² + y²).
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
export function hypot(x, y) {
|
||||
// eslint-disable-next-line no-restricted-properties -- ±∞ / NaN only: those results are exact by the spec
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return Math.hypot(x, y);
|
||||
const m = Math.max(Math.abs(x), Math.abs(y));
|
||||
if (m > P500) { const a = x * PM600, b = y * PM600; return Math.sqrt(a * a + b * b) * P600; }
|
||||
if (m < PM500 && m !== 0) { const a = x * P600, b = y * P600; return Math.sqrt(a * a + b * b) * PM600; }
|
||||
return Math.sqrt(x * x + y * y);
|
||||
}
|
||||
|
||||
// ---- integer powers ----------------------------------------------------------------------------------------------
|
||||
|
||||
const SPLIT = 134217729; // 2^27 + 1 (Dekker)
|
||||
const ACC = [0, 0];
|
||||
const BASE = [0, 0];
|
||||
/** (ah + al)·(bh + bl) in double-double, into out (TwoProduct by Dekker's split: no FMA) */
|
||||
function mul2(ah, al, bh, bl, out) {
|
||||
const p = ah * bh;
|
||||
let t = SPLIT * ah;
|
||||
const ahh = t - (t - ah), ahl = ah - ahh;
|
||||
t = SPLIT * bh;
|
||||
const bhh = t - (t - bh), bhl = bh - bhh;
|
||||
const e = ((ahh * bhh - p) + ahh * bhl + ahl * bhh) + ahl * bhl + (ah * bl + al * bh);
|
||||
const s = p + e;
|
||||
out[0] = s;
|
||||
out[1] = e - (s - p);
|
||||
}
|
||||
|
||||
/**
|
||||
* xⁿ for an integer n (the sim's powers are stack counts, chain bounces …). The square-and-multiply runs in
|
||||
* double-double and rounds once, so the result is correctly rounded in practice; a non-integer exponent or a huge
|
||||
* base falls back to Math.pow.
|
||||
* @param {number} x
|
||||
* @param {number} n
|
||||
*/
|
||||
export function powi(x, n) {
|
||||
// eslint-disable-next-line no-restricted-properties -- outside the integer domain only (never in the sim)
|
||||
if (!Number.isInteger(n) || !Number.isFinite(x) || Math.abs(x) > P500) return Math.pow(x, n);
|
||||
if (n < 0) return 1 / powi(x, -n);
|
||||
ACC[0] = 1; ACC[1] = 0; BASE[0] = x; BASE[1] = 0;
|
||||
let k = n;
|
||||
while (k > 0) {
|
||||
if (k % 2 === 1) mul2(ACC[0], ACC[1], BASE[0], BASE[1], ACC);
|
||||
k = Math.floor(k / 2);
|
||||
if (k > 0) mul2(BASE[0], BASE[1], BASE[0], BASE[1], BASE);
|
||||
}
|
||||
return ACC[0] + ACC[1];
|
||||
}
|
||||
|
||||
// ---- sin / cos (fdlibm 5.3) --------------------------------------------------------------------------------------
|
||||
|
||||
const S1 = -1.66666666666666324348e-01, S2 = 8.33333333332248946124e-03, S3 = -1.98412698298579493134e-04;
|
||||
const S4 = 2.75573137070700676789e-06, S5 = -2.50507602534068634195e-08, S6 = 1.58969099521155010221e-10;
|
||||
/** sin on [-π/4, π/4]; y is the tail of x, iy = 0 when y is 0 */
|
||||
function kSin(x, y, iy) {
|
||||
if ((hi(x) & 0x7fffffff) < 0x3e400000 && Math.trunc(x) === 0) return x;
|
||||
const z = x * x, v = z * x;
|
||||
const r = S2 + z * (S3 + z * (S4 + z * (S5 + z * S6)));
|
||||
if (iy === 0) return x + v * (S1 + z * r);
|
||||
return x - ((z * (0.5 * y - v * r) - y) - v * S1);
|
||||
}
|
||||
|
||||
const C1 = 4.16666666666666019037e-02, C2 = -1.38888888888741095749e-03, C3 = 2.48015872894767294178e-05;
|
||||
const C4 = -2.75573143513906633035e-07, C5 = 2.08757232129817482790e-09, C6 = -1.13596475577881948265e-11;
|
||||
/** cos on [-π/4, π/4]; y is the tail of x */
|
||||
function kCos(x, y) {
|
||||
const ix = hi(x) & 0x7fffffff;
|
||||
if (ix < 0x3e400000 && Math.trunc(x) === 0) return 1;
|
||||
const z = x * x;
|
||||
const r = z * (C1 + z * (C2 + z * (C3 + z * (C4 + z * (C5 + z * C6)))));
|
||||
if (ix < 0x3fd33333) return 1 - (0.5 * z - (z * r - x * y));
|
||||
const qx = ix > 0x3fe90000 ? 0.28125 : fromWords(ix - 0x00200000, 0);
|
||||
return (1 - qx) - ((0.5 * z - qx) - (z * r - x * y));
|
||||
}
|
||||
|
||||
const NPIO2_HW = [
|
||||
0x3FF921FB, 0x400921FB, 0x4012D97C, 0x401921FB, 0x401F6A7A, 0x4022D97C, 0x4025FDBB, 0x402921FB, 0x402C463A, 0x402F6A7A,
|
||||
0x4031475C, 0x4032D97C, 0x40346B9C, 0x4035FDBB, 0x40378FDB, 0x403921FB, 0x403AB41B, 0x403C463A, 0x403DD85A, 0x403F6A7A,
|
||||
0x40407E4C, 0x4041475C, 0x4042106C, 0x4042D97C, 0x4043A28C, 0x40446B9C, 0x404534AC, 0x4045FDBB, 0x4046C6CB, 0x40478FDB,
|
||||
0x404858EB, 0x404921FB,
|
||||
];
|
||||
const INV_PIO2 = 6.36619772367581382433e-01;
|
||||
const PIO2_1 = 1.57079632673412561417e+00, PIO2_1T = 6.07710050650619224932e-11;
|
||||
const PIO2_2 = 6.07710050630396597660e-11, PIO2_2T = 2.02226624879595063154e-21;
|
||||
const PIO2_3 = 2.02226624871116645580e-21, PIO2_3T = 8.47842766036889956997e-32;
|
||||
const TWO_PI = 6.283185307179586;
|
||||
const Y = [0, 0];
|
||||
|
||||
/** x − n·π/2 into Y (head, tail); returns n. |x| ≤ 2^19·π/2: fdlibm's medium path; beyond that (never in the sim)
|
||||
* x is first taken modulo the double nearest 2π (exact %), which stays deterministic though not accurate. */
|
||||
function remPio2(x0) {
|
||||
let x = x0;
|
||||
if ((hi(x) & 0x7fffffff) > 0x413921fb) x = x % TWO_PI;
|
||||
const hx = hi(x), ix = hx & 0x7fffffff;
|
||||
if (ix <= 0x3fe921fb) { Y[0] = x; Y[1] = 0; return 0; }
|
||||
if (ix < 0x4002d97c) { // |x| < 3π/4: n = ±1
|
||||
if (hx > 0) {
|
||||
let z = x - PIO2_1;
|
||||
if (ix !== 0x3ff921fb) { Y[0] = z - PIO2_1T; Y[1] = (z - Y[0]) - PIO2_1T; } else { z -= PIO2_2; Y[0] = z - PIO2_2T; Y[1] = (z - Y[0]) - PIO2_2T; }
|
||||
return 1;
|
||||
}
|
||||
let z = x + PIO2_1;
|
||||
if (ix !== 0x3ff921fb) { Y[0] = z + PIO2_1T; Y[1] = (z - Y[0]) + PIO2_1T; } else { z += PIO2_2; Y[0] = z + PIO2_2T; Y[1] = (z - Y[0]) + PIO2_2T; }
|
||||
return -1;
|
||||
}
|
||||
let t = Math.abs(x);
|
||||
const n = Math.trunc(t * INV_PIO2 + 0.5);
|
||||
let r = t - n * PIO2_1, w = n * PIO2_1T;
|
||||
Y[0] = r - w;
|
||||
if (!(n < 32 && ix !== NPIO2_HW[n - 1])) {
|
||||
const j = ix >> 20;
|
||||
if (j - ((hi(Y[0]) >> 20) & 0x7ff) > 16) { // a 2nd step, good to 118 bits
|
||||
t = r; w = n * PIO2_2; r = t - w; w = n * PIO2_2T - ((t - r) - w); Y[0] = r - w;
|
||||
if (j - ((hi(Y[0]) >> 20) & 0x7ff) > 49) { // a 3rd, 151 bits
|
||||
t = r; w = n * PIO2_3; r = t - w; w = n * PIO2_3T - ((t - r) - w); Y[0] = r - w;
|
||||
}
|
||||
}
|
||||
}
|
||||
Y[1] = (r - Y[0]) - w;
|
||||
if (hx < 0) { Y[0] = -Y[0]; Y[1] = -Y[1]; return -n; }
|
||||
return n;
|
||||
}
|
||||
|
||||
/** @param {number} x radians */
|
||||
export function sin(x) {
|
||||
if (!Number.isFinite(x)) return NaN;
|
||||
if ((hi(x) & 0x7fffffff) <= 0x3fe921fb) return kSin(x, 0, 0);
|
||||
switch (remPio2(x) & 3) {
|
||||
case 0: return kSin(Y[0], Y[1], 1);
|
||||
case 1: return kCos(Y[0], Y[1]);
|
||||
case 2: return -kSin(Y[0], Y[1], 1);
|
||||
default: return -kCos(Y[0], Y[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} x radians */
|
||||
export function cos(x) {
|
||||
if (!Number.isFinite(x)) return NaN;
|
||||
if ((hi(x) & 0x7fffffff) <= 0x3fe921fb) return kCos(x, 0);
|
||||
switch (remPio2(x) & 3) {
|
||||
case 0: return kCos(Y[0], Y[1]);
|
||||
case 1: return -kSin(Y[0], Y[1], 1);
|
||||
case 2: return -kCos(Y[0], Y[1]);
|
||||
default: return kSin(Y[0], Y[1], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- atan2 (fdlibm, FreeBSD msun) --------------------------------------------------------------------------------
|
||||
|
||||
const ATAN_HI = [4.63647609000806093515e-01, 7.85398163397448278999e-01, 9.82793723247329054082e-01, 1.57079632679489655800e+00];
|
||||
const ATAN_LO = [2.26987774529616870924e-17, 3.06161699786838301793e-17, 1.39033110312309984516e-17, 6.12323399573676603587e-17];
|
||||
const AT = [
|
||||
3.33333333333329318027e-01, -1.99999999998764832476e-01, 1.42857142725034663711e-01, -1.11111104054623557880e-01,
|
||||
9.09088713343650656196e-02, -7.69187620504482999495e-02, 6.66107313738753120669e-02, -5.83357013379057348645e-02,
|
||||
4.97687799461593236017e-02, -3.65315727442169155270e-02, 1.62858201153657823623e-02,
|
||||
];
|
||||
/** @param {number} x0 */
|
||||
function atan(x0) {
|
||||
let x = x0;
|
||||
const hx = hi(x), ix = hx & 0x7fffffff;
|
||||
let id;
|
||||
if (ix >= 0x44100000) { // |x| ≥ 2^66
|
||||
if (ix > 0x7ff00000 || (ix === 0x7ff00000 && lo(x) !== 0)) return x + x;
|
||||
return hx > 0 ? ATAN_HI[3] + ATAN_LO[3] : -ATAN_HI[3] - ATAN_LO[3];
|
||||
}
|
||||
if (ix < 0x3fdc0000) { // |x| < 0.4375
|
||||
if (ix < 0x3e200000) return x;
|
||||
id = -1;
|
||||
} else {
|
||||
x = Math.abs(x);
|
||||
if (ix < 0x3ff30000) {
|
||||
if (ix < 0x3fe60000) { id = 0; x = (2 * x - 1) / (2 + x); } else { id = 1; x = (x - 1) / (x + 1); }
|
||||
} else if (ix < 0x40038000) { id = 2; x = (x - 1.5) / (1 + 1.5 * x); } else { id = 3; x = -1 / x; }
|
||||
}
|
||||
const z = x * x, w = z * z;
|
||||
const s1 = z * (AT[0] + w * (AT[2] + w * (AT[4] + w * (AT[6] + w * (AT[8] + w * AT[10])))));
|
||||
const s2 = w * (AT[1] + w * (AT[3] + w * (AT[5] + w * (AT[7] + w * AT[9]))));
|
||||
if (id < 0) return x - x * (s1 + s2);
|
||||
const r = ATAN_HI[id] - ((x * (s1 + s2) - ATAN_LO[id]) - x);
|
||||
return hx < 0 ? -r : r;
|
||||
}
|
||||
|
||||
const PI_O_4 = 7.8539816339744827900E-01, PI_O_2 = 1.5707963267948965580E+00, PI = 3.1415926535897931160E+00;
|
||||
const PI_LO = 1.2246467991473532e-16;
|
||||
/**
|
||||
* The angle of (x, y), in (−π, π].
|
||||
* @param {number} y
|
||||
* @param {number} x
|
||||
*/
|
||||
export function atan2(y, x) {
|
||||
if (Number.isNaN(x) || Number.isNaN(y)) return NaN;
|
||||
const hx = hi(x), ix = hx & 0x7fffffff, lx = lo(x);
|
||||
const hy = hi(y), iy = hy & 0x7fffffff, ly = lo(y);
|
||||
if (((hx - 0x3ff00000) | lx) === 0) return atan(y); // x = 1
|
||||
let m = ((hy >>> 31) & 1) | ((hx >>> 30) & 2); // 2·sign(x) + sign(y)
|
||||
if ((iy | ly) === 0) return m === 2 ? PI : m === 3 ? -PI : y; // y = ±0
|
||||
if ((ix | lx) === 0) return hy < 0 ? -PI_O_2 : PI_O_2; // x = ±0
|
||||
if (ix === 0x7ff00000) { // x = ±∞
|
||||
if (iy === 0x7ff00000) return [PI_O_4, -PI_O_4, 3 * PI_O_4, -3 * PI_O_4][m];
|
||||
return [0, -0, PI, -PI][m];
|
||||
}
|
||||
if (iy === 0x7ff00000) return hy < 0 ? -PI_O_2 : PI_O_2; // y = ±∞
|
||||
const k = (iy - ix) >> 20;
|
||||
let z;
|
||||
if (k > 60) { z = PI_O_2 + 0.5 * PI_LO; m &= 1; } // |y/x| > 2^60
|
||||
else if (hx < 0 && k < -60) z = 0; // |y|/x < −2^60
|
||||
else z = atan(Math.abs(y / x));
|
||||
switch (m) {
|
||||
case 0: return z;
|
||||
case 1: return -z;
|
||||
case 2: return PI - (z - PI_LO);
|
||||
default: return (z - PI_LO) - PI;
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -27,6 +27,7 @@
|
||||
|
||||
import { MOVE_SCALE, COLS } from './constants.js';
|
||||
import { straightClear } from './grid.js';
|
||||
import { hypot } from './detmath.js';
|
||||
|
||||
/** 半径10格 — the fan's radius (tiles). */
|
||||
export const FEAR_RADIUS = 10;
|
||||
@@ -80,7 +81,7 @@ const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c,
|
||||
*/
|
||||
export function reachableTiles(b, e, st) {
|
||||
if (!st || st.self) return [];
|
||||
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = Math.hypot(dx, dy);
|
||||
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = hypot(dx, dy);
|
||||
if (!(d > 1e-9)) return [];
|
||||
const ux = dx / d, uy = dy / d;
|
||||
const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
|
||||
@@ -90,7 +91,7 @@ export function reachableTiles(b, e, st) {
|
||||
const out = [];
|
||||
for (let r = R.r0; r <= R.r1; r++) {
|
||||
for (let c = R.c0; c <= R.c1; c++) {
|
||||
const vx = c - st.hx, vy = r - st.hy, L = Math.hypot(vx, vy);
|
||||
const vx = c - st.hx, vy = r - st.hy, L = hypot(vx, vy);
|
||||
if (L > FEAR_RADIUS + 1e-9) continue;
|
||||
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue;
|
||||
if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue;
|
||||
@@ -219,7 +220,7 @@ export function moveFeared(b, e, dt) {
|
||||
for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
|
||||
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
|
||||
const p = m.pts[m.i];
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = hypot(dx, dy);
|
||||
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
|
||||
moved = moved || d > 1e-9;
|
||||
}
|
||||
|
||||
+3
-2
@@ -54,6 +54,7 @@
|
||||
// off-centre unit may walk straight to a tile centre (ai.js planLeg, fear.js: else it steps back to its tile centre).
|
||||
|
||||
import { ROWS, COLS } from './constants.js';
|
||||
import { hypot } from './detmath.js';
|
||||
|
||||
/** Move cost of an obstacle-like tile (client `Tile.get_moveCost`: obstacle-like ? 1000 : 1). */
|
||||
export const OBSTACLE_COST = 1000;
|
||||
@@ -376,7 +377,7 @@ export class Grid {
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
const a = stack[i], b = f.next[a];
|
||||
const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS;
|
||||
f.len[a] = f.len[b] + Math.hypot(br - ar, bc - ac);
|
||||
f.len[a] = f.len[b] + hypot(br - ar, bc - ac);
|
||||
}
|
||||
return f.len[k];
|
||||
}
|
||||
@@ -420,7 +421,7 @@ export class Grid {
|
||||
/** Length (tiles) of a polyline of [r,c] points. */
|
||||
static pathLength(pts) {
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
||||
for (let i = 1; i < pts.length; i++) len += hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
||||
return len;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// mid-flight the projectile fizzles, unless `hitDead: true` (then it lands at the last known position).
|
||||
|
||||
import { PROJECTILE_SPEED } from './constants.js';
|
||||
import { hypot } from './detmath.js';
|
||||
|
||||
let seq = 0;
|
||||
const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -55,7 +56,7 @@ export class ProjectileSystem {
|
||||
else p.target = null;
|
||||
}
|
||||
const dx = p.tx - p.x, dy = p.ty - p.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const d = hypot(dx, dy);
|
||||
const step = p.speed * dt;
|
||||
if (d <= step || p.age >= p.maxAge) {
|
||||
p.x = p.tx; p.y = p.ty;
|
||||
|
||||
+1
-1
@@ -320,7 +320,7 @@ export function resultDigest(result) {
|
||||
const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
|
||||
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
|
||||
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 2 ** 31 ? v : null);
|
||||
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 0x80000000 ? v : null);
|
||||
const keyOr = (v) => (isKey(v) ? v : null);
|
||||
|
||||
function compactMods(m) {
|
||||
|
||||
+11
-11
@@ -1085,26 +1085,26 @@
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1987984,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1988064,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,10,9,139430,0,0,91,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_narant",2331,1,11,5,155980,0,0,88,3],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_narant",2331,1,1,0,740,0,0,5,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_narant",2541,1,3,1,12484,0,0,4,1],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,16,222860,0,0,87,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,255194,0,0,170,4],
|
||||
["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,18,305504,0,0,107,1],
|
||||
["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,23,447750,0,0,177,4],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,18,216963,0,0,85,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,262559,0,0,170,4],
|
||||
["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,19,302382,0,0,106,1],
|
||||
["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,27,452511,0,0,177,4],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,2000,1,2,0,251,0,0,5,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"none",2331,1,4,0,12573,0,0,4,1],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,18,198679,0,0,90,3],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,10,236538,0,0,173,4]
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,10,197025,0,0,89,3],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,11,235164,0,0,173,4]
|
||||
],
|
||||
"enemies": {"enemy_1010_demon":[54,54,0,0,755827],"enemy_1320_wdrrl_2":[18,18,0,0,839808],"enemy_1422_lrsldr_2":[39,39,0,0,400308]},
|
||||
"hooks": [1,123,5886,1001,1001,2296,2296,0,111,111,25,20,0,9735,233,0,111,0,1,111,0,0,0],
|
||||
"events": {"atk":1001,"deploy":12,"die":111,"dmg":2296,"engage":12,"fx":453,"skill":45,"spawn":123,"status":245},
|
||||
"fx": {"aoe":137,"bounce":316},
|
||||
"snaps": "b054f186 5f1791df 62fad2b9 772b925e 3c33e5ff 792df80b b43d3d8f 4283ce62 933a9c03 44991c3e b9347ef2 941827a5 73c09893 9ddf9d2c dbaf0a96 63e46fbe d21c76f5 55bdfa26 bfaf6c80 6968f2d8"
|
||||
"hooks": [1,123,5886,997,997,2292,2292,0,111,111,25,20,0,9744,235,0,111,0,1,111,0,0,0],
|
||||
"events": {"atk":997,"deploy":12,"die":111,"dmg":2292,"engage":12,"fx":451,"skill":45,"spawn":123,"status":251},
|
||||
"fx": {"aoe":137,"bounce":314},
|
||||
"snaps": "b054f186 5f1791df 3d017ddc 6caa5d05 8c321828 7e0d74c4 924d8c48 bc44fa99 448a38b4 f74013c9 0e587d4d 2185ab3b 9482eaf7 c6e6f386 7ce17fa6 af9ad4b3 97bb47c5 500c935b 824afe6d 6e3d1bcb"
|
||||
},
|
||||
"diy-028": {
|
||||
"about": "6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=logos/S1 5_diy2_a=logos/S2 5_diy1_a=logos/S3 5_diy2_b=logos/S1 5_diy1_b=logos/S2 5_diy2_b=logos/S3",
|
||||
|
||||
+19
-19
@@ -755,7 +755,7 @@
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act2autochess_m03","bossId":"boss_4","hiddenBossId":"boss_9","factions":["FLY","TIMES","REFLECTION"]},
|
||||
"end": {"victory":false,"roundsPassed":11,"hiddenReached":false,"hiddenCleared":false,"reason":"eliminated","teamLp":null,"bossPoolLeft":null,"virtualMs":115700},
|
||||
"rng": {"setup":25,"shop":525,"waves":0,"draft":75,"bots":24735,"meta":10},
|
||||
"rng": {"setup":25,"shop":510,"waves":0,"draft":75,"bots":24735,"meta":8},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
@@ -808,27 +808,27 @@
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"ai_0": [1,3,12,0,1,4,8,"47bf424b",157,"deputShip 2/24, indomShip 1/17, kazimierzShip 1/28, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",36,53,17,76],
|
||||
"ai_0": [1,3,12,0,0,4,8,"e73e84c6",170,"deputShip 2/24, indomShip 1/29, kazimierzShip 1/29, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",32,48,16,76],
|
||||
"ai_1": [1,25,12,0,0,4,8,"fec52ed0",54,"arcaneShip 1/17, indomShip 1/18, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/7, visiShip 0/4, yanShip 0/2",19,43,24,76],
|
||||
"ai_2": [1,15,12,0,0,4,8,"549a87ca",142,"deputShip 1/12, indomShip 1/12, siracusaShip 1/88, skillfulShip 1/2, soloShip 1/18, suntShip 1/10, swiftShip 0/6, yanShip 0/1",52,59,7,80]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"ai_0": [0,0,13,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,88],
|
||||
"ai_0": [0,0,12,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,86],
|
||||
"ai_1": [1,15,12,0,0,5,8,"fd30bf27",65,"arcaneShip 1/17, indomShip 1/24, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/12, visiShip 0/4, yanShip 0/2",8,18,10,88],
|
||||
"ai_2": [1,10,12,0,0,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,91]
|
||||
"ai_2": [1,10,12,0,2,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,92]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88],
|
||||
"ai_1": [1,5,12,0,0,5,8,"6eebc22c",74,"arcaneShip 2/31, indomShip 1/28, kazimierzShip 0/11, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/21, victoriaShip 1/15, visiShip 0/4, yanShip 0/2",5,25,20,101],
|
||||
"ai_2": [1,3,12,0,0,5,8,"a5108c6b",202,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/130, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",18,25,7,103]
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86],
|
||||
"ai_1": [1,5,12,0,1,5,8,"38b1f273",92,"arcaneShip 2/29, indomShip 1/38, kazimierzShip 0/7, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/25, visiShip 0/4, yanShip 0/2",7,25,18,99],
|
||||
"ai_2": [1,4,14,0,0,5,8,"a5108c6b",208,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/136, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",19,25,6,105]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88],
|
||||
"ai_1": [0,0,12,0,0,5,0,"811c9dc5",0,"arcaneShip 0/45, indomShip 0/38, kazimierzShip 0/11, siracusaShip 0/12, swiftShip 0/21, victoriaShip 0/24, visiShip 0/4, yanShip 0/2",5,57,52,116],
|
||||
"ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/136, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",13,57,44,116]
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86],
|
||||
"ai_1": [0,0,13,0,0,6,0,"811c9dc5",0,"arcaneShip 0/35, indomShip 0/48, kazimierzShip 0/7, siracusaShip 0/12, swiftShip 0/17, victoriaShip 0/34, visiShip 0/4, yanShip 0/2",5,57,52,110],
|
||||
"ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/142, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",14,57,43,118]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
@@ -839,12 +839,12 @@
|
||||
"eliminatedRound": 10,
|
||||
"lp": 0,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"trophies": 2,
|
||||
"reward": 191,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1727766,"gold":88,"itemsEquipped":9,"kills":225,"leaks":38,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":13},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1678337,"gold":86,"itemsEquipped":8,"kills":221,"leaks":37,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":12},
|
||||
"lineup": [],
|
||||
"bonds": ["deputShip 0/28","indomShip 0/41","kazimierzShip 0/33","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"]
|
||||
"bonds": ["deputShip 0/28","indomShip 0/53","kazimierzShip 0/34","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"]
|
||||
},
|
||||
"ai_1": {
|
||||
"alive": false,
|
||||
@@ -853,12 +853,12 @@
|
||||
"eliminatedRound": 12,
|
||||
"lp": 0,
|
||||
"bandId": "band_sarkazb",
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"trophies": 2,
|
||||
"reward": 255,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2389171,"gold":116,"itemsEquipped":19,"kills":220,"leaks":120,"lpLost":50,"merges":7,"perfectRounds":6,"refreshes":12},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2293866,"gold":110,"itemsEquipped":10,"kills":222,"leaks":118,"lpLost":50,"merges":5,"perfectRounds":6,"refreshes":10},
|
||||
"lineup": [],
|
||||
"bonds": ["arcaneShip 0/45","indomShip 0/38","kazimierzShip 0/11","siracusaShip 0/12","swiftShip 0/21","victoriaShip 0/24","visiShip 0/4","yanShip 0/2"]
|
||||
"bonds": ["arcaneShip 0/35","indomShip 0/48","kazimierzShip 0/7","siracusaShip 0/12","swiftShip 0/17","victoriaShip 0/34","visiShip 0/4","yanShip 0/2"]
|
||||
},
|
||||
"ai_2": {
|
||||
"alive": false,
|
||||
@@ -867,12 +867,12 @@
|
||||
"eliminatedRound": 12,
|
||||
"lp": 0,
|
||||
"bandId": "band_lmlee",
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"trophies": 2,
|
||||
"reward": 255,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3734992,"gold":116,"itemsEquipped":8,"kills":216,"leaks":100,"lpLost":34,"merges":6,"perfectRounds":2,"refreshes":16},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3450226,"gold":118,"itemsEquipped":9,"kills":218,"leaks":98,"lpLost":33,"merges":6,"perfectRounds":2,"refreshes":17},
|
||||
"lineup": [],
|
||||
"bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/136","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"]
|
||||
"bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/142","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// server/sim/detmath.js — the sim's engine-independent math (hypot, powi, sin, cos, atan2). The functions use only
|
||||
// + − × ÷ and Math.sqrt, so their results are the same bits on every engine; the digests below pin them (the same
|
||||
// digests hold in Chrome, Firefox and Safari). Also: the special values of ECMA-262, powi correctly rounded and hypot
|
||||
// within 1 ulp against exact BigInt references, and no sim file calls an implementation-approximated Math function.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hypot, powi, sin, cos, atan2 } from '../../server/sim/detmath.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const lcg = (seed) => { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; };
|
||||
const WORDS = new Uint32Array(2), F = new Float64Array(WORDS.buffer);
|
||||
/** FNV-1a over the IEEE 754 bits of the values */
|
||||
const digest = (values) => {
|
||||
let h = 0x811c9dc5;
|
||||
for (const v of values) { F[0] = v; for (const w of WORDS) for (let k = 0; k < 4; k++) { h ^= (w >>> (8 * k)) & 0xff; h = Math.imul(h, 16777619); } }
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
};
|
||||
const N = 20000;
|
||||
|
||||
test('the results are pinned bit for bit (engine-independent digests)', () => {
|
||||
const runs = {
|
||||
hypot: () => { const r = lcg(1); return Array.from({ length: N }, () => hypot((r() * 2 - 1) * 20, (r() < 0.3 ? 0 : (r() * 2 - 1) * 20))); },
|
||||
powi: () => { const r = lcg(2); return Array.from({ length: N }, () => powi(r() * 3, Math.floor(r() * 41))); },
|
||||
sin: () => { const r = lcg(3); return Array.from({ length: N }, () => sin((r() * 2 - 1) * 4 * Math.PI)); },
|
||||
cos: () => { const r = lcg(4); return Array.from({ length: N }, () => cos((r() * 2 - 1) * 4 * Math.PI)); },
|
||||
atan2: () => { const r = lcg(5); return Array.from({ length: N }, () => atan2((r() * 2 - 1) * 20, (r() * 2 - 1) * 20)); },
|
||||
};
|
||||
const want = { hypot: '6e3e3a7d', powi: 'f80a3234', sin: '73aedcb1', cos: '51b52432', atan2: 'a0ab93f0' };
|
||||
for (const [name, run] of Object.entries(runs)) assert.equal(digest(run()), want[name], name);
|
||||
});
|
||||
|
||||
test('values that tell the engines apart', () => {
|
||||
// V8's Math.hypot(3, 2) is 3.6055512754639896; correctly rounded √13 is 3.605551275463989 (also Firefox, Safari)
|
||||
assert.equal(hypot(3, 2), 3.605551275463989);
|
||||
// fdlibm (Node 24–26); Chrome 154's correctly rounded Math.sin / Math.atan2 give 0.7931254945086103 / 0.6023179867387402
|
||||
assert.equal(sin(2.22566898269194), 0.7931254945086104);
|
||||
assert.equal(cos(0.74090688893123), 0.7378567508940902);
|
||||
assert.equal(atan2(0.5098986645868919, 0.74162210852462), 0.6023179867387403);
|
||||
// correctly rounded; Safari's Math.pow squares and multiplies in double and gives 2.593742460100002
|
||||
assert.equal(powi(1.1, 10), 2.5937424601000023);
|
||||
assert.equal(powi(0.85, 3), 0.6141249999999999);
|
||||
});
|
||||
|
||||
test('special values follow ECMA-262', () => {
|
||||
const same = (a, b, msg) => assert.ok(Object.is(a, b), `${msg}: ${a} vs ${b}`);
|
||||
for (const [x, y, want] of [[Infinity, NaN, Infinity], [NaN, -Infinity, Infinity], [NaN, 1, NaN], [0, 0, 0], [-0, -0, 0], [0, -2, 2], [-3, 0, 3]]) same(hypot(x, y), want, `hypot(${x}, ${y})`);
|
||||
for (const x of [0, -0]) { same(sin(x), x, 'sin ±0'); same(cos(x), 1, 'cos ±0'); }
|
||||
for (const x of [Infinity, -Infinity, NaN]) { same(sin(x), NaN, `sin(${x})`); same(cos(x), NaN, `cos(${x})`); }
|
||||
const P = Math.PI, H = Math.PI / 2, Q = Math.PI / 4;
|
||||
const table = [
|
||||
[0, 1, 0], [-0, 1, -0], [0, -1, P], [-0, -1, -P], [0, 0, 0], [-0, 0, -0], [0, -0, P], [-0, -0, -P],
|
||||
[1, 0, H], [-1, 0, -H], [1, -0, H], [-1, -0, -H],
|
||||
[1, Infinity, 0], [-1, Infinity, -0], [1, -Infinity, P], [-1, -Infinity, -P],
|
||||
[Infinity, 1, H], [-Infinity, 1, -H], [Infinity, Infinity, Q], [-Infinity, Infinity, -Q], [Infinity, -Infinity, 3 * Q], [-Infinity, -Infinity, -3 * Q],
|
||||
[NaN, 1, NaN], [1, NaN, NaN], [1, 1, Q], [-1, -1, -3 * Q],
|
||||
];
|
||||
for (const [y, x, want] of table) same(atan2(y, x), want, `atan2(${y}, ${x})`);
|
||||
same(powi(2, 0), 1, 'x^0'); same(powi(0, 0), 1, '0^0'); same(powi(0, 3), 0, '0^3'); same(powi(-2, 3), -8, '(-2)^3');
|
||||
same(powi(2, -2), 0.25, 'x^-n'); same(powi(Infinity, 2), Infinity, '∞²'); same(powi(NaN, 0), 1, 'NaN^0');
|
||||
});
|
||||
|
||||
// exact references: x = M·2^E with M an integer
|
||||
const DV = new DataView(new ArrayBuffer(8));
|
||||
const decompose = (x) => {
|
||||
DV.setFloat64(0, Math.abs(x));
|
||||
const hiw = DV.getUint32(0), low = DV.getUint32(4), be = (hiw >>> 20) & 0x7ff;
|
||||
const M = (BigInt(hiw & 0xfffff) << 32n) | BigInt(low);
|
||||
return be === 0 ? [M, -1074] : [M | (1n << 52n), be - 1075];
|
||||
};
|
||||
const bitlen = (n) => n.toString(2).length;
|
||||
/** round M·2^e (M > 0) to the nearest double, ties to even; `sticky` = the true value is a little above M·2^e */
|
||||
const roundBig = (M, e, sticky = false) => {
|
||||
const L = bitlen(M);
|
||||
if (L <= 53 && !sticky) return Number(M) * 2 ** e;
|
||||
const sh = BigInt(Math.max(0, L - 53));
|
||||
let q = M >> sh;
|
||||
const rem = M & ((1n << sh) - 1n), half = sh > 0n ? 1n << (sh - 1n) : 0n;
|
||||
if (rem > half || (rem === half && (sticky || (q & 1n)))) q++;
|
||||
return Number(q) * 2 ** (e + Number(sh));
|
||||
};
|
||||
const isqrt = (n) => { if (n < 2n) return n; let x = BigInt(Math.floor(Math.sqrt(Number(n)))); for (;;) { const y = (x + n / x) >> 1n; if (y >= x) { while (x * x > n) x--; while ((x + 1n) * (x + 1n) <= n) x++; return x; } x = y; } };
|
||||
const exactHypot = (x, y) => {
|
||||
if (x === 0 || y === 0) return Math.abs(x || y);
|
||||
const [mx, ex] = decompose(x), [my, ey] = decompose(y), E = Math.min(ex, ey);
|
||||
const S = (mx * mx << BigInt(2 * (ex - E))) + (my * my << BigInt(2 * (ey - E)));
|
||||
const k = Math.max(0, Math.ceil((130 - bitlen(S)) / 2));
|
||||
const q = isqrt(S << BigInt(2 * k));
|
||||
return roundBig(q, E - k, q * q !== S << BigInt(2 * k));
|
||||
};
|
||||
const exactPowi = (x, n) => { if (n === 0) return 1; const [M, E] = decompose(x); return roundBig(M ** BigInt(n), E * n); };
|
||||
const ORD = new BigInt64Array(F.buffer);
|
||||
const ulps = (a, b) => { F[0] = a; const ia = ORD[0]; F[0] = b; return Number(ia - ORD[0]); };
|
||||
|
||||
test('powi is correctly rounded (exact BigInt reference)', () => {
|
||||
for (let b = 1; b <= 300; b++) for (let n = 0; n <= 24; n++) {
|
||||
const x = b / 100;
|
||||
assert.equal(powi(x, n), exactPowi(x, n), `${x}^${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('hypot is within 1 ulp of √(x² + y²), and symmetric', () => {
|
||||
const r = lcg(7);
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const x = (r() * 2 - 1) * 20, y = (r() * 2 - 1) * 20;
|
||||
assert.ok(Math.abs(ulps(hypot(x, y), exactHypot(x, y))) <= 1, `hypot(${x}, ${y})`);
|
||||
assert.equal(hypot(x, y), hypot(y, x)); assert.equal(hypot(-x, y), hypot(x, y));
|
||||
}
|
||||
for (const s of [1e300, 1e-300]) assert.ok(Math.abs(ulps(hypot(3 * s, 4 * s), 5 * s)) <= 1, `rescaled ${s}`);
|
||||
});
|
||||
|
||||
test('sin, cos and atan2 stay within 2 ulp of the engine and keep their symmetries', () => {
|
||||
const r = lcg(9);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const x = (r() * 2 - 1) * 10;
|
||||
assert.ok(Math.abs(ulps(sin(x), Math.sin(x))) <= 2 && Math.abs(ulps(cos(x), Math.cos(x))) <= 2, `x = ${x}`);
|
||||
assert.ok(Object.is(sin(-x), -sin(x)) && Object.is(cos(-x), cos(x)), `symmetry at ${x}`);
|
||||
const y = (r() * 2 - 1) * 10;
|
||||
assert.ok(Math.abs(ulps(atan2(y, x), Math.atan2(y, x))) <= 2, `atan2(${y}, ${x})`);
|
||||
assert.ok(Object.is(atan2(-y, x), -atan2(y, x)), `atan2 odd in y at (${y}, ${x})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the sim never calls an implementation-approximated Math function', () => {
|
||||
const APPROX = /\bMath\s*\.\s*(acos|acosh|asin|asinh|atan|atanh|atan2|cbrt|cos|cosh|exp|expm1|hypot|log|log1p|log10|log2|pow|sin|sinh|tan|tanh)\b/;
|
||||
const files = [];
|
||||
const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) files.push(p); } };
|
||||
walk(join(ROOT, 'server/sim'));
|
||||
assert.ok(files.length > 50);
|
||||
const bad = [];
|
||||
for (const f of files) {
|
||||
const rel = relative(ROOT, f).replaceAll('\\', '/');
|
||||
if (rel === 'server/sim/detmath.js') continue;
|
||||
const code = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
|
||||
if (APPROX.test(code)) bad.push(`${rel}: ${code.match(APPROX)[0]}`);
|
||||
}
|
||||
assert.deepEqual(bad, [], 'use server/sim/detmath.js');
|
||||
});
|
||||
Reference in new issue
Block a user