From 12fa92d9f602ba0b4dde597118ffe6fb775a02f1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:01:27 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20O7:=20engine=20hooks=20for=20the=20?= =?UTF-8?q?=E6=94=BB=E5=9F=8E=E6=89=8B=20/=20=E5=9B=9E=E7=8E=AF=E5=B0=84?= =?UTF-8?q?=E6=89=8B=20kits=20=E2=80=94=20targeting=20priority=20'heaviest?= =?UTF-8?q?'=20and=20boomerang=20flight=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - targeting.js PRIORITY_FNS `heaviest` (-e.weight: the current 重量等级, 失重 included): the 攻城手 trait "优先攻击重量最重的敌人" for the 早露 / 提丰 kits (`trait.priority`); no other unit uses it. - ai.js throwBoomerang: a catch emits the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (only when a handler exists); an attack profile's `boomerangOnward(ctx)` takes the flight over after the first hit — ctx.hit(target, x, y) (a hit of the same attack, resolveHit with its profile), ctx.comeBack(x, y) (the usual way back, once) — and a content error there still sends the boomerang back. For 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时", S1's bounces and S2's dash (the kits come in the next commit). - docs/SIM.md §5 (the hook) and §8 (the profile flags, the loopshooter row, the priority). - Additive and behaviour-preserving: no unit of the base has a handler or the flag, so golden is unchanged. --- docs/SIM.md | 12 ++++++++---- server/sim/ai.js | 26 +++++++++++++++++++++++--- server/sim/targeting.js | 3 +++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 67b576a8..56777bab 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -750,6 +750,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `enemySpawn` / `enemyLeak` | `{ enemy }` | | | `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused | | `dodge` | `{ source, target, dmg }` | an attack was dodged | +| `boomerangCaught` | `{ unit, attackId, isSkill, x, y }` | a 回环射手 boomerang came back to its thrower (`ai.js throwBoomerang`; `isSkill` = thrown by a skill attack): 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时" | | `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) | | `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` | | `battleEnd` | `{ result }` | may still add layer gains / coins | @@ -1141,9 +1142,12 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun / 3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, -`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays -`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets -`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js). +`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait +"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `boomerang` (the projectile stays +`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its +first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter +row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the +header of professions.js). | sub | behaviour | |---|---| @@ -1154,7 +1158,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) | | bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) | | hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty | -| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) | +| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) | | reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction | | chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit | | funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) | diff --git a/server/sim/ai.js b/server/sim/ai.js index f40b4882..5e061be6 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -185,19 +185,39 @@ export function performAttack(b, u, prof, targets, opts = null) { * current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1: * the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing * returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one). + * Content: a catch fires the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (娜仁图亚 LPS-Y "每回收5次回旋投射物", + * S3 "投射物全部回收时"); an attack profile with `boomerangOnward(ctx)` (a skill's attack override: 娜仁图亚 S1's bounces, + * S2's dash) takes the flight over after the first hit — ctx { battle, unit, profile, target, x, y, attackId, isSkill, + * home(), hit(target, x, y) (resolveHit with this attack's profile), comeBack(x, y) (sends it back from there, once; + * returns the return projectile or null) } — and a content error there sends it back from the hit point. */ function throwBoomerang(b, u, prof, t, info) { const seq = u.deploySeq; u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1; const home = () => u.alive && u.deployed && u.deploySeq === seq; + let sent = false; + const comeBack = (x, y) => { + if (sent || !home()) return null; + sent = true; + // hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home + return b.addProjectile({ from: { x, y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true, + onHit: (r) => { + if (!home() || !(u.trait.boomerangsOut > 0)) return; + u.trait.boomerangsOut--; + if (b._hooks.boomerangCaught) b.emit('boomerangCaught', { unit: u, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, x: r.x, y: r.y }); + } }); + }; b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true, onHit: (c) => { // (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle) if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u); if (!home()) return; - // hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home - b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true, - onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } }); + if (typeof prof.boomerangOnward === 'function') { + const ctx = { battle: b, unit: u, profile: prof, target: c.target, x: c.x, y: c.y, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, + home, comeBack, hit: (tgt, x, y) => b._safe(() => resolveHit(b, u, prof, tgt, info, x, y), 'boomerang.hit', u) }; + if (b._safe(() => { prof.boomerangOnward(ctx); return true; }, 'boomerang.onward', u) === true) return; + } + comeBack(c.x, c.y); } }); } diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 5d2ebcf9..4c4101e2 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -178,6 +178,9 @@ const PRIORITY_FNS = { boss: (e) => (e.isBoss ? 0 : 1), notBurst: (e) => (e.s.flags.burstLock ? 1 : 0), ground: (e) => (e.isFlying ? 1 : 0), + // 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级 + // (Unit.weight: massLevel with 失重 etc.) first, the usual order after that + heaviest: (e) => -e.weight, }; /**