feedback7: merge fb7-tick — whole-frame attack intervals and time-SP costs take exactly that many frames, 1 s = 30 not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2: 1e-9 on the ally / enemy attack countdown, the 双眼皮 turret and 寒檀's icicles, gainSp / _normalize; PRTS 作战机制 帧对齐); golden: every family moves (281 of 283, end-result flips in §27.61); union OK

This commit is contained in:
sganggs committed 2026-10-09 01:42:16 +08:00
commit e1b75e1308
21 files changed
+5008 -4776

No files matched your search

+13 -2
View File
@@ -75,8 +75,19 @@ export function effectiveProfile(u) {
// ---------------------------------------------------------------------------------------------------------------
// ally attack loop
/**
* One tick off an attack cooldown. What is left within 1e-9 of 0 is 0 (the engine's timer tolerance, as skills.js
* `timeLeft`, buff intervals and `every()`): 1 s counted down in thirty steps of 1/30 leaves 2.1e-16 in floating point,
* which held every attack whose interval is a whole number of ticks — 1 s, 3 s, 4 s … — one tick longer (PR #402; PRTS
* 作战机制 帧对齐: 1 s is 30 frames). A non-integer count still ends on the tick that crosses 0 (docs/SIM.md §2).
*/
export function attackCountdown(cd, dt) {
const left = cd - dt;
return left > 1e-9 ? left : 0;
}
export function updateAlly(b, u, dt) {
if (u.atkCd > 0 && u.canAct) u.atkCd = Math.max(0, u.atkCd - dt);
if (u.atkCd > 0 && u.canAct) u.atkCd = attackCountdown(u.atkCd, dt);
if (u.blocking.length) enforceBlockCapacity(b, u);
if (!u.canAct || !u.profile) return;
// a rangeExtend change (buff added / expired) rebuilds the range — also for units that never attack (auras)
@@ -542,7 +553,7 @@ export function updateEnemy(b, e, dt) {
if (e.s.flags.levitate && e.unbalanceUntil > b.time) e.unbalanceUntil = -Infinity;
const unbalanced = b.time < e.unbalanceUntil - 1e-9; // (the 1e-9: a float-summed end such as 35/30 s keeps its frame)
const prevCd = e.atkCd;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = attackCountdown(e.atkCd, dt);
// a stun / freeze / sleep / 浮空 / 失衡 — or leaving the field — takes the enemy out of its attack: a swing short of its
// damage frame does not land, and the attack starts again from its wind-up afterwards (enemyAttack)
if (e.swing && (stunned || e.hidden || unbalanced)) e.swing = false;
+2 -2
View File
@@ -34,7 +34,7 @@
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
import { performAttack, attackCountdown } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
import { hypot } from '../detmath.js';
@@ -241,7 +241,7 @@ const TURRET_PROFILE = Object.freeze({ attack: 'ranged', dmgType: 'arts', projec
function tickTurret(battle, u, dt) {
const T = u.mem.turret;
if (!u.alive || !T) return;
T.cd -= dt;
T.cd = attackCountdown(T.cd, dt); // (the engine's attack countdown: a whole number of ticks takes exactly that many)
if (T.cd > 0 || u.s.flags.stun) return;
const L = u.ownerId != null ? topLayers(battle, u.ownerId) : 0;
const bonus = Math.min(L * T.aspdPer, T.aspdMax);
@@ -2,6 +2,7 @@
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
import { COLS } from '../../../constants.js';
import { attackCountdown } from '../../../ai.js';
import { toLocal } from '../../../dir.js';
import { num, talent, skillGrid, batPct, mods, lazySkills, whileOn } from '../shared/tier5.js';
@@ -33,7 +34,7 @@ export default {
attack: { noAttack: true },
onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; },
onTick({ battle, unit, dt }) {
unit.mem.iceCd -= dt;
unit.mem.iceCd = attackCountdown(unit.mem.iceCd, dt); // (the engine's attack countdown, PR #402)
if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return;
unit.mem.iceCd = unit.s.interval;
+6 -4
View File
@@ -338,9 +338,11 @@ export class SkillRuntime {
}
if (cost <= 0) return 0; // free skills never recharge (see reset)
this.sp += amt;
while (this.sp >= cost && this.charges < this.maxCharges) {
// within 1e-9 of the cost is the cost (the timer tolerance of `timeLeft` below; PR #402): 300 time gains of 1/30 SP
// add up to 9.999999999999975 in floating point, which held a 10-SP charge one tick
while (this.sp > cost - 1e-9 && this.charges < this.maxCharges) {
this.charges++;
if (this.charges < this.maxCharges) this.sp -= cost;
if (this.charges < this.maxCharges) this.sp = Math.max(0, this.sp - cost);
else this.sp = cost;
}
if (this.charges >= this.maxCharges) this.sp = cost;
@@ -352,9 +354,9 @@ export class SkillRuntime {
if (this.noSkill || this.kind === 'passive') return;
const cost = this.spCost;
if (cost <= 0) { if (this.sp > 0) { this.sp = 0; this.charges = this.maxCharges; } return; } // became free
while (this.sp >= cost && this.charges < this.maxCharges) {
while (this.sp > cost - 1e-9 && this.charges < this.maxCharges) { // (the 1e-9 of gainSp)
this.charges++;
this.sp = this.charges < this.maxCharges ? this.sp - cost : cost;
this.sp = this.charges < this.maxCharges ? Math.max(0, this.sp - cost) : cost;
}
if (this.sp > cost) this.sp = cost;
}