diff --git a/CURRENT_CHECKPOINT.md b/CURRENT_CHECKPOINT.md index b354aac9..5faafc04 100644 --- a/CURRENT_CHECKPOINT.md +++ b/CURRENT_CHECKPOINT.md @@ -10,7 +10,8 @@ Accepted P8.2 closure: `d308f8bc14b043ed133ce2d99cbce461aecec81f` Rejected P8.3 source: `5f0cb2695ba149b1efcab27a882ade00a275c6ac` P8.3 documentation closure: `fab5eebf4260ab3da42dee8abe63dd7b7588789b` P8.3R1 candidate source: `169e3877fa262da3db7495d61bc77ce061704ae7` -Stage: `P8_3R1_OWNER_REVIEW` +P8.3R1 documentation closure: `5f4e4cdadee6e0f6a457ab57a55964f7fd4c38a5` +Stage: `P8_3R2_FRONTLINE_BEHAVIOR_ACTIVE` The Phase 6 battle, VFX and equipment direction is Owner-accepted. Phase 7 turns the existing 50-screen design inventory into an individually viewable, @@ -128,5 +129,15 @@ Supporting motion evidence is the 8.76-second 1280x720 WebM at `prototype/gallery/evidence/p8-3r1-motion-review-169e387-attempt02/` with SHA-256 `55f7389afb08efa3a7e7b61b38618a1ffa6f4f850a533d8107bfba27944cdb3a`. -Shortest next action: publish the fixed P8.3R1 bundle for Owner play and wait -for the Owner decision without starting P8.4. +Owner review rejected P8.3R1 because the units animated in fixed positions but +did not visibly advance to collision, stop at contact, preserve a rear healer +and ranged attacker, or replace a fallen frontline footprint. P8.3R2 is the +only active repair. It must bind visible geometry and HP changes to one state +chain: two-sided entry, collision stop, post-contact melee, Kindhand healing an +injured ally, Farstring attacking from the rear, a separately labeled PF-B06 +spell-language demonstration that does not add a fifth Company member, and one +no-respawn frontline replacement. Victory/defeat, formal casualty, reward, +recovery and P8.4 remain frozen. + +Shortest next action: implement and browser-verify the P8.3R2 frontline +behavior chain from a clean source before producing any new candidate evidence. diff --git a/docs/goals/PHASE8_EXPERIENCE_PROTOTYPE_GOAL.md b/docs/goals/PHASE8_EXPERIENCE_PROTOTYPE_GOAL.md index 1b2f8cc3..c88dbbef 100644 --- a/docs/goals/PHASE8_EXPERIENCE_PROTOTYPE_GOAL.md +++ b/docs/goals/PHASE8_EXPERIENCE_PROTOTYPE_GOAL.md @@ -377,3 +377,49 @@ For the active repaired package: or `Aetherbound Guild P8.3R1 redesign required: ` + +## P8.3R2 Frontline Behavior Repair + +Owner review on 2026-08-12 rejected P8.3R1 as an incomplete battle model. Its +sprites animate, but the formations remain at fixed positions, so the page does +not show the reference behavior the Owner explicitly requires: opposing lines +move until physical contact, melee units stop and fight there, the healer and +ranged attacker remain behind the frontline, and the next living front unit +advances when a frontline footprint falls. + +P8.3R2 reopens only this visible behavior boundary. The accepted P8.1/P8.2 +Company remains exactly `OFF-03`, `MKT-R1-01`, `OFF-04`, `MKT-R1-02`. No fifth +ally may be invented. The future spell role is demonstrated using the authored +`PF-B06 Riftcaller` action language from the enemy rear with an explicit +prototype-only label; it does not alter Company ownership or imply that the +current four-person Company contains a mage. + +The repaired state chain must prove: + +1. both formations enter from opposite sides and visibly translate; +2. the frontline centers stop at one marked contact boundary; +3. Wayrunner and Hearthguard attack only after contact; +4. Kindhand remains behind contact, selects a visibly injured ally and raises + that ally's displayed HP; +5. Farstring remains behind contact, launches a projectile and lowers one + legal enemy's displayed HP; +6. the PF-B06 demonstration has a distinct charge, spell bolt and impact + language and is labeled as enemy-side behavior rather than a fifth ally; +7. a fallen front footprint remains down with no same-battle respawn while the + next living frontline unit advances to the contact boundary; +8. Pause freezes formation translation, sprite animation, projectiles and event + progress; `1x/2x/4x` changes presentation speed only; and +9. the terminal still withholds victory/defeat, formal casualties, rewards and + recovery for P8.4. + +P8.3R1 evidence remains rejected append-only history and cannot be reused. +Godot/runtime, authoritative battle formulas, new final art/audio, device, +packaging, store and release work remain frozen. + +P8.3R2 legal terminals are: + +`Aetherbound Guild P8.3R2 frontline behavior experience candidate ready` + +or + +`Aetherbound Guild P8.3R2 redesign required: ` diff --git a/docs/presentation/PHASE8_EXPERIENCE_PROTOTYPE_CONTRACT.md b/docs/presentation/PHASE8_EXPERIENCE_PROTOTYPE_CONTRACT.md index ceaf7e13..cf485141 100644 --- a/docs/presentation/PHASE8_EXPERIENCE_PROTOTYPE_CONTRACT.md +++ b/docs/presentation/PHASE8_EXPERIENCE_PROTOTYPE_CONTRACT.md @@ -151,3 +151,28 @@ handoff. random draws or alter the terminal event ID. - A Recruit at zero HP never respawns in the same battle and no Reserve enters automatically. P8.3 states the rule but does not resolve a casualty. + +## P8.3R2 Visible Frontline Behavior + +- The accepted four-Recruit Company enters in the saved P8.2 order. Company + ownership never changes and no fifth ally is created. +- After entry, role movement is explicit rather than a silent order rewrite: + Wayrunner and Hearthguard close to one marked contact boundary, while + Kindhand and Farstring preserve readable rear spacing. +- Melee attacks are illegal before contact. Collision ends frontline + translation before either melee action is presented. +- Kindhand selects a visible injured ally and raises that exact unit's HP. + Farstring launches a separate projectile from behind contact and lowers one + legal enemy's HP. +- The authored `PF-B06 Riftcaller` cast language may be previewed from the + enemy rear only. Its label must state that it is a future behavior example, + not a fifth member of the accepted Company. +- When the demonstration's enemy front reaches zero HP, that footprint remains + visibly down. The next living enemy advances to the same contact boundary; + no respawn or Reserve entry is legal. +- Pause freezes event time, formation translation, frame animation and live + projectiles. Speed changes presentation time only and cannot change any + position, target, HP delta or event order. +- These are executable presentation facts, not authoritative combat formulas + or a casualty transaction. Victory, defeat, casualty ownership, rewards and + recovery remain withheld for P8.4. diff --git a/docs/reviews/PHASE8_EXPERIENCE_PROTOTYPE_LEDGER.md b/docs/reviews/PHASE8_EXPERIENCE_PROTOTYPE_LEDGER.md index aa1937d1..fb6c5e7e 100644 --- a/docs/reviews/PHASE8_EXPERIENCE_PROTOTYPE_LEDGER.md +++ b/docs/reviews/PHASE8_EXPERIENCE_PROTOTYPE_LEDGER.md @@ -12,3 +12,4 @@ Append-only record. Automated and human evidence remain separate. | 2026-08-12 | source `5f0cb26`; `p8-3-candidate-5f0cb26-attempt01` | `P8_3_AUTOMATED_CANDIDATE_PASS` | Clean/upstream fixed source passes `ABG_P8_3_BROWSER_OK risks=3 preselected=0 commits=1 opening_rules=2 events=6 pause_freeze=1 speeds=1,2,4 inspect_readonly=1 result_mutation=0 responsive=desktop,phone_landscape,phone_portrait bundle=offline`, P8.1/P8.2 and complete Phase 7 browser regressions, design validation, anti-dominance, single-equipment/individual-level and asset-provenance gates. Agent visual review found risk composition, same-line battle opening/live view, causal overlay and desktop/phone layouts complete and unclipped; the causal overlay is an explicit paused inspection layer. The 53-row inventory verifies 53/53; inventory SHA-256 `3ad48cfa135a29f01f75ed69dc07f6a2037fbf984c3d94b7cae21da2199af1c8`, P8.3 browser log `f34ec892e12dfd5022182de2b5f3f68687a77e1066a78aa2fe8e81bdd3ea7e21`, P8.2 regression log `3139beb66a7f265a40c29e02518ad63ffa802bb10f9ab819cb62cf7e39060747`, P8.1 regression log `c38118d8b66bb1270e625586f5d9ae77af85ae60ae0c1ab88631a2f764fc6a8b`, Phase 7 regression log `b2b8329301b944af8a9301da4a4c493007730f66a310e6942de67ba00a3343c8`, bundle `5989cb87c5e422d26bd1cdc5d2e74db4177090b32540d1cb53b77560bec943e9`. Owner comprehension/choice/visual acceptance remains open; P8.4 remains frozen. | | 2026-08-12 | Owner review of published P8.3 | `P8_3_OWNER_REJECTED_DYNAMIC_BATTLE_REPAIR_ACTIVE` | Owner screenshot review found that the automatic-battle page was a static card/timeline presentation and asked why it did not have the reference game's visible animation. This rejects the prior P8.3 battle visual presentation despite its automated contract pass. Reopen P8.3 only for a dynamic same-line arena with visible entry, advance, attack, projectile, Guard/intercept, hit and sustain feedback. Risk/commit/event/result authority remains unchanged and P8.4 remains frozen. | | 2026-08-12 | source `169e387`; `p8-3r1-candidate-169e387-attempt01`; motion review attempt 02 | `P8_3R1_DYNAMIC_BATTLE_CANDIDATE_PASS` | Clean/upstream fixed source passes `ABG_P8_3R1_BROWSER_OK risks=3 preselected=0 commits=1 arena=continuous_line allies=4 entry=advance motion=idle,advance,attack,ranged,guard,hit,sustain events=6 pause_freeze=event_and_frame speeds=1,2,4 inspect_readonly=1 result_mutation=0 responsive=desktop,phone_landscape,phone_portrait bundle=offline`, P8.1/P8.2/50-screen regressions and unchanged design/economy/loadout gates. Asset provenance verifies actors=6, equipment=12, VFX=16, Recruit motion=32 and enemy motion=30, total 96. Formal 64-row evidence inventory verifies 64/64, SHA-256 `336a6e1757470648d269c2206834fc95df2bc67013315d50c1a185cf1fac5b06`; P8.3R1 log `d86fb7527efc89a9c764ce94a818e71696406c9f9c9c70e54dc0d8ac18d0ea63`; bundle `c8d57710162a928dea583b54786afc58e61ec2d80aa8a546d01259a06d4cd717`. Separate 8.76-second 1280x720 WebM `55f7389afb08efa3a7e7b61b38618a1ffa6f4f850a533d8107bfba27944cdb3a` proves continuous frame change and action sequence for agent review. Hearthguard/Riftblade use accepted frames; the other three identities use prototype-only motion transforms, so final profession animation coverage remains open. Owner visual/comprehension acceptance and P8.4 remain open/frozen. | +| 2026-08-12 | Owner review of P8.3R1 | `P8_3R1_OWNER_REJECTED_P8_3R2_ACTIVE` | Owner found that fixed-position animation still did not reproduce the required battle behavior: two lines must move to physical collision, melee must stop and fight at contact, Kindhand must heal an injured ally from the rear, Farstring must attack from the rear, later spell behavior must read distinctly, and the next living frontline footprint must advance after a fall. P8.3R2 owns only this behavior repair. The accepted four-person Company stays frozen; PF-B06 spell language must be explicitly enemy-side/prototype-only and cannot invent a fifth ally. P8.4, results, formal casualties, rewards, recovery, runtime, device and release remain frozen. | diff --git a/prototype/site/app.js b/prototype/site/app.js index 02d0f77e..a83ae236 100644 --- a/prototype/site/app.js +++ b/prototype/site/app.js @@ -692,12 +692,15 @@ const p83RiskOffers = [ ]; const p83BattleEvents = [ - { time: "00:00.0", title: ["Prepared line locked", "整备横队已锁定"], detail: ["Rear-left to front-right order enters unchanged.", "从左后排到右前排的站位原样进入战斗。"], kind: "order" }, - { time: "00:02.4", title: ["Open-Line Cord checks the front neighbor", "开阵系带检查前方相邻位置"], detail: ["Mael stands ahead of Tomas, so EQ-012 cancels the reserved long target and Tomas retargets the nearest legal enemy.", "Mael 位于 Tomas 前方,因此 EQ-012 取消远距离预定目标,Tomas 改为攻击最近的合法敌人。"], kind: "equipment" }, - { time: "00:04.8", title: ["Mael intercepts pressure aimed at Sable", "Mael 拦截了指向 Sable 的压力"], detail: ["Their adjacent order spends one Guard before the hit reaches Sable.", "两人的相邻站位先消耗一层护盾,攻击没有直接落到 Sable 身上。"], kind: "protect" }, - { time: "00:07.2", title: ["Ilya reaches the enemy rear", "Ilya 命中敌方后排"], detail: ["Farstring reach bypasses the guarded front and interrupts the rear-pressure sequence.", "远弦射手的射程绕过前排护盾,打断了敌方后排压力。"], kind: "reach" }, - { time: "00:09.6", title: ["Sable restores the exposed front", "Sable 治疗了暴露前排"], detail: ["Position 1 remains adjacent to Ilya at position 0, so Shared Cup resolves legally.", "1号位仍与0号位的 Ilya 相邻,因此共饮药杯合法生效。"], kind: "sustain" }, - { time: "00:12.0", title: ["Presentation timeline resolved", "演示时间线解析完成"], detail: ["The result transaction is deliberately withheld for P8.4.", "胜负与结算事务特意留到 P8.4。"], kind: "resolved" }, + { time: "00:00.0", title: ["Both formations enter", "双方横队进入战场"], detail: ["The accepted Company order enters from the left while the enemy line enters from the right.", "已确认的四人横队从左侧入场,敌方横队从右侧入场。"], kind: "entry" }, + { time: "00:01.8", title: ["Frontlines collide and stop", "前排接敌并停止推进"], detail: ["Wayrunner and Hearthguard form the melee screen at the marked contact boundary; Kindhand and Farstring keep rear spacing.", "迅行者与炉卫在标记的接触线上组成近战前排;愈手与远弦保持后排距离。"], kind: "contact" }, + { time: "00:03.2", title: ["Melee acts only after contact", "近战只在接敌后出手"], detail: ["Tomas retargets the nearest legal enemy and Mael follows with Shield Knock; the enemy front loses 28 HP.", "Tomas 改为攻击最近的合法敌人,Mael 随后盾击;敌方前排损失 28 点生命。"], kind: "melee" }, + { time: "00:04.8", title: ["Mael intercepts rear pressure", "Mael 拦截后排压力"], detail: ["Guard absorbs part of the hit aimed through the line. Mael is visibly injured from 100 to 72 HP; Sable remains safe.", "护盾吸收了穿过横队的部分攻击;Mael 的生命从 100 降至 72,Sable 保持安全。"], kind: "protect" }, + { time: "00:06.4", title: ["Ilya fires from behind the frontline", "Ilya 从前排后方射击"], detail: ["Farstring remains behind both melee footprints, launches one arrow and damages the legal rearmost enemy from 100 to 72 HP.", "远弦保持在两名近战之后,射出一箭,使合法的敌方后排生命从 100 降至 72。"], kind: "ranged" }, + { time: "00:08.0", title: ["Sable heals the injured ally", "Sable 治疗受伤队友"], detail: ["Kindhand selects Mael as the lowest-ratio injured ally and restores him from 72 to 88 HP; it is not a self-heal.", "愈手选择生命比例最低的受伤队友 Mael,将其生命从 72 恢复到 88;这不是自我治疗。"], kind: "heal" }, + { time: "00:09.6", title: ["Enemy rear demonstrates spell pressure", "敌方后排演示法术压力"], detail: ["A rear enemy uses the PF-B06 Riftcaller cast language: charge, forked bolt and impact. It is not a fifth Company member.", "敌方后排采用 PF-B06 裂隙召唤师的施法语言:蓄力、分叉法弹、命中;它不是我方第五名队员。"], kind: "spell" }, + { time: "00:11.2", title: ["The next frontline unit advances", "下一名存活前排自动补进"], detail: ["The demonstration front footprint stays down with no respawn; the next living enemy advances to the same contact boundary without Reserve entry.", "演示中的前排倒下后保持倒地且不会复活;下一名存活敌人前进到同一接触线,没有后备入场。"], kind: "replacement" }, + { time: "00:12.8", title: ["Behavior demonstration complete", "战斗行为演示完成"], detail: ["Positions and HP remain visible, while victory, defeat, casualties, rewards and recovery stay withheld for P8.4.", "站位和生命变化继续可见;胜负、伤亡、奖励与恢复仍留到 P8.4。"], kind: "resolved" }, ]; const experienceSteps = [ @@ -1521,91 +1524,124 @@ function p83EnemyLine(offer) { function p83MotionSprite(side, motion, speed, className = "") { const frames = p83MotionAssets[side][motion] || p83MotionAssets[side].idle; const fps = motion === "idle" ? 6 : motion === "advance" ? 10 : 12; - const duration = frames.length / fps / Math.max(1, speed); + const duration = frames.length / fps; const frameDuration = duration / frames.length; - return `${frames.map((src, index) => ``).join("")}`; + return `${frames.map((src, index) => ``).join("")}`; +} + +const p83ContactX = 52.5; + +function p83FormationState(eventIndex, frozen = false) { + const entryAllies = [7, 17, 27, 37]; + const engagedAllies = [46.5, 36, 16, 26]; + const entryEnemies = [72, 82, 92]; + const engagedEnemies = [58.2, 69.5, 80.8]; + return { + phase: frozen ? "entry" : eventIndex === 0 ? "closing" : eventIndex >= 7 ? "replacement" : "contact", + translating: !frozen && eventIndex === 0, + allyFrom: entryAllies, + allyX: frozen ? entryAllies : engagedAllies, + enemyFrom: entryEnemies, + enemyX: frozen ? entryEnemies : eventIndex >= 7 ? [58.2, 58.2, 69.5] : engagedEnemies, + }; } function p83AllyMotion(offer, eventIndex) { if (offer.profession === "Hearthguard") { - if (eventIndex === 0) return "advance"; - if (eventIndex === 2) return "protect"; + if (eventIndex <= 1) return "advance"; + if (eventIndex === 2) return "attack"; + if (eventIndex === 3) return "protect"; return "idle"; } - if (eventIndex === 0) return "advance"; - if (eventIndex === 1 && offer.profession === "Wayrunner") return "attack"; - if (eventIndex === 3 && offer.profession === "Farstring") return "ranged"; - if (eventIndex === 4 && offer.profession === "Kindhand") return "sustain"; + if (eventIndex <= 1 && offer.profession === "Wayrunner") return "advance"; + if (eventIndex === 2 && offer.profession === "Wayrunner") return "attack"; + if (eventIndex === 4 && offer.profession === "Farstring") return "ranged"; + if (eventIndex === 5 && offer.profession === "Kindhand") return "sustain"; return "idle"; } function p83EnemyMotion(index, eventIndex) { - if (eventIndex === 0) return "advance"; - if (eventIndex === 1 && index === 0) return "hit"; - if (eventIndex === 2 && index === 1) return "attack"; - if (eventIndex === 3 && index > 0) return "hit"; - if (eventIndex === 4 && index === 0) return "telegraph"; + if (eventIndex <= 1) return "advance"; + if (eventIndex === 2 && index === 0) return "hit"; + if (eventIndex === 3 && index === 0) return "attack"; + if (eventIndex === 4 && index > 0) return "hit"; + if (eventIndex === 6 && index > 0) return "telegraph"; + if (eventIndex >= 7 && index === 0) return "fallen"; + if (eventIndex === 7 && index === 1) return "advance"; return "idle"; } function p83AllyHealth(index, eventIndex) { const base = [88, 100, 82, 92][index]; - if (eventIndex < 2) return base; - if (index === 2 && eventIndex === 2) return 69; - if (index === 2 && eventIndex >= 4) return 84; + if (index === 1 && eventIndex >= 3 && eventIndex < 5) return 72; + if (index === 1 && eventIndex >= 5) return 88; + if (index === 0 && eventIndex >= 6) return 76; return base; } -function p83EnemyHealth(index, eventIndex) { - const damage = [eventIndex >= 1 ? 14 : 0, eventIndex >= 3 ? 28 : 0, eventIndex >= 3 ? 18 : 0][index] || 0; - return 100 - damage; +function p83EnemyHealth(index, count, eventIndex) { + if (index === 0 && eventIndex >= 7) return 0; + if (index === 0 && eventIndex >= 2) return 72; + if (index === count - 1 && eventIndex >= 4) return 72; + return 100; } -function p83AllyFighter(offer, index, eventIndex) { +function p83AllyFighter(offer, index, eventIndex, formation) { const motion = p83AllyMotion(offer, eventIndex); const isFrameAnimated = offer.profession === "Hearthguard"; const body = isFrameAnimated ? p83MotionSprite("recruit", motion === "ranged" || motion === "sustain" ? "idle" : motion, state.riskBattleSpeed, "p83-fighter-frame") : `${actorImage(offer, "p83-fighter-art")}`; - return `
-
${localizedText(offer.profession)}${localizedText(offer.name)}
+ const x = formation.allyX[index]; + const hp = p83AllyHealth(index, eventIndex); + const lineRole = ["Wayrunner", "Hearthguard"].includes(offer.profession) ? "front" : "rear"; + return `
+
${localizedText(offer.profession)}${localizedText(offer.name)}
${body}
`; } -function p83EnemyFighter(enemy, index, count, eventIndex) { +function p83EnemyFighter(enemy, index, count, eventIndex, formation) { const motion = p83EnemyMotion(index, eventIndex); - return `
-
${bi("ENEMY", "敌人")} 0${index + 1}${bi(enemy[0], enemy[1])}
+ const x = formation.enemyX[index] ?? formation.enemyX.at(-1); + const hp = p83EnemyHealth(index, count, eventIndex); + const fallen = index === 0 && eventIndex >= 7; + const casterDemo = index === count - 1 && eventIndex === 6; + return `
+
${bi("ENEMY", "敌人")} 0${index + 1}${bi(enemy[0], enemy[1])}
${enemy[0] === "Unknown" ? `?` : p83MotionSprite("enemy", motion, state.riskBattleSpeed, "p83-fighter-frame")}
`; } -function p83ArenaEffects(eventIndex) { - if (eventIndex === 1) return `-14
${equipmentImage(firstMarketItem.name, "")}EQ-012 · ${bi("RETARGET", "改变目标")}
`; - if (eventIndex === 2) return `${bi("GUARD", "格挡")}`; - if (eventIndex === 3) return `-28`; - if (eventIndex === 4) return `+15`; +function p83ArenaEffects(eventIndex, enemyCount) { + if (eventIndex === 1) return `
${bi("CONTACT · STOP", "接敌 · 停止推进")}
`; + if (eventIndex === 2) return `
${bi("MELEE CONTACT", "近战接触")}
-28
${equipmentImage(firstMarketItem.name, "")}EQ-012 · ${bi("LEGAL RETARGET", "合法改换目标")}
`; + if (eventIndex === 3) return `${bi("GUARD · 100→72", "格挡 · 100→72")}`; + if (eventIndex === 4) return `100→72`; + if (eventIndex === 5) return `72→88`; + if (eventIndex === 6) return `
PF-B06 · ${bi("RIFTCALLER LANGUAGE", "裂隙召唤师动作语言")}${bi("Enemy-side behavior demo · not a fifth ally", "敌方行为演示 · 不是我方第五人")}
88→76`; + if (eventIndex === 7) return `
${bi("NEXT LIVING FRONT ADVANCES", "下一名存活前排补进")}
${bi("DOWN · STAYS DOWN", "倒下 · 不会复活")}${bi("No Reserve entry", "无后备入场")}
`; return ""; } function p83BattleArena(offer, eventIndex, frozen = false) { const company = p83PreparedCompany(); - return `
+ const formation = p83FormationState(eventIndex, frozen); + return `
${bi("COMPANY", "我方队伍")}4
${bi("ENEMIES", "敌方单位")}${offer.enemies.length}
- ${company.map((unit, index) => p83AllyFighter(unit, index, eventIndex)).join("")} - ${offer.enemies.map((enemy, index) => p83EnemyFighter(enemy, index, offer.enemies.length, eventIndex)).join("")} - ${p83ArenaEffects(eventIndex)} + ${company.map((unit, index) => p83AllyFighter(unit, index, eventIndex, formation)).join("")} + ${offer.enemies.map((enemy, index) => p83EnemyFighter(enemy, index, offer.enemies.length, eventIndex, formation)).join("")} + ${p83ArenaEffects(eventIndex, offer.enemies.length)}
${bi("REAR", "后排")} ←
→ ${bi("PRESSURE FRONT", "接敌前线")}
`; } function renderP83BattleOpening(screen) { const offer = p83RiskOffer(); - const body = `
P8.3R1 · ${bi("BATTLE OPENING", "战斗开场")}

${bi("Both lines are ready to advance", "双方横队即将自动推进接敌")}

${bi("NO COMBAT RESPAWN", "战斗中不会复活")}${bi("NO RESERVE AUTO-ENTRY", "后备队员不会自动补位")}

${bi("Start releases both lines. Observation controls cannot issue attacks or skills.", "开始后双方会自动移动、接敌和行动;观察控制不能下达攻击或技能指令。")}

${p83BattleArena(offer, 0, true)}
${offer.id} · ${bi("risk and order frozen", "风险与站位已冻结")}
`; + const body = `
P8.3R2 · ${bi("FRONTLINE BEHAVIOR", "前线行为演示")}

${bi("Both lines will move until contact", "双方会推进到接触线,再自动战斗")}

${bi("NO COMBAT RESPAWN", "战斗中不会复活")}${bi("NO RESERVE AUTO-ENTRY", "后备队员不会自动补位")}

${bi("Watch melee stop at contact, rear healing and ranged attacks, a distinct spell demonstration, then frontline replacement.", "你将看到近战接敌停步、后排治疗与远射、独立法术演示,以及前排倒下后的补进。")}

${p83BattleArena(offer, 0, true)}
${offer.id} · ${bi("four-person Company frozen", "四人队伍已冻结")}
`; return frame(screen, body, { variant: "p8-3-opening", resources: false, artSrc: "assets/web/ch01_battle.webp" }); } @@ -1616,7 +1652,7 @@ function renderP83Battle(screen) { const inspected = p83BattleEvents[state.riskInspectEvent === null ? eventIndex : state.riskInspectEvent]; const visible = p83BattleEvents.slice(0, eventIndex + 1); const progress = eventIndex / (p83BattleEvents.length - 1) * 100; - const body = `
P8.3R1 · ${bi("ANIMATED AUTOMATIC BATTLE", "动态自动战斗")}

${bi(current.title[0], current.title[1])}

${[1,2,4].map((speed) => ``).join("")}
${bi("No combat respawn", "战斗中不会复活")}${bi("No Reserve auto-entry", "后备不会自动补位")}${state.riskBattlePaused ? bi("PAUSED", "已暂停") : `${state.riskBattleSpeed}x · ${bi("AUTO", "自动")}`}
${p83BattleArena(offer, eventIndex)}
${current.time} · ${bi("CURRENT ACTION", "当前动作")}${bi(current.title[0], current.title[1])}

${bi(current.detail[0], current.detail[1])}

${p83BattleEvents.map((event, index) => ``).join("")}
${eventIndex === p83BattleEvents.length - 1 ? `
${bi("Battle presentation complete; result is still withheld", "战斗演示完成;胜负结果仍未揭示")}
` : ""}${state.riskInspectOpen ? `
${bi("PINNED CAUSE", "固定因果事件")} · ${inspected.time}

${bi(inspected.title[0], inspected.title[1])}

${bi(inspected.detail[0], inspected.detail[1])}

${bi("Risk", "风险")}
${offer.id}
${bi("Prepared order", "整备站位")}
OFF-03 → MKT-R1-01 → OFF-04 → MKT-R1-02
${bi("Observation", "观察")}
${bi("Motion paused; no progress or result mutation", "动作已暂停;不会改变进度或结果")}
` : ""}
`; + const body = `
P8.3R2 · ${bi("FRONTLINE BEHAVIOR", "前线行为演示")}

${bi(current.title[0], current.title[1])}

${[1,2,4].map((speed) => ``).join("")}
${bi("No combat respawn", "战斗中不会复活")}${bi("No Reserve auto-entry", "后备不会自动补位")}${state.riskBattlePaused ? bi("PAUSED", "已暂停") : `${state.riskBattleSpeed}x · ${bi("AUTO", "自动")}`}
${p83BattleArena(offer, eventIndex)}
${current.time} · ${bi("CURRENT ACTION", "当前动作")}${bi(current.title[0], current.title[1])}

${bi(current.detail[0], current.detail[1])}

${p83BattleEvents.map((event, index) => ``).join("")}
${eventIndex === p83BattleEvents.length - 1 ? `
${bi("Behavior demonstration complete; result is still withheld", "战斗行为演示完成;胜负结果仍未揭示")}
` : ""}${state.riskInspectOpen ? `
${bi("PINNED CAUSE", "固定因果事件")} · ${inspected.time}

${bi(inspected.title[0], inspected.title[1])}

${bi(inspected.detail[0], inspected.detail[1])}

${bi("Risk", "风险")}
${offer.id}
${bi("Prepared order", "整备站位")}
OFF-03 → MKT-R1-01 → OFF-04 → MKT-R1-02
${bi("Observation", "观察")}
${bi("Motion paused; no progress or result mutation", "动作已暂停;不会改变进度或结果")}
` : ""}
`; return frame(screen, body, { variant: "p8-3-battle", resources: false, artSrc: "assets/web/ch01_battle.webp" }); } @@ -1789,6 +1825,26 @@ function scheduleP83BattleTimer() { }, delay); } +function syncP83PlaybackSurface() { + const battle = $(".p83-battle"); + if (!battle) return; + battle.classList.toggle("is-paused", state.riskBattlePaused); + battle.classList.remove("speed-1", "speed-2", "speed-4"); + battle.classList.add(`speed-${state.riskBattleSpeed}`); + const pause = battle.querySelector("[data-p83-pause]"); + if (pause) { + pause.textContent = state.riskBattlePaused ? "▶" : "Ⅱ"; + pause.setAttribute("aria-label", state.riskBattlePaused ? bi("Resume", "继续") : bi("Pause", "暂停")); + } + battle.querySelectorAll("[data-p83-speed]").forEach((button) => button.classList.toggle("is-active", Number(button.dataset.p83Speed) === state.riskBattleSpeed)); + const status = battle.querySelector(".p83-battle-rules b"); + if (status) status.textContent = state.riskBattlePaused ? bi("PAUSED", "已暂停") : `${state.riskBattleSpeed}x · ${bi("AUTO", "自动")}`; + battle.getAnimations({ subtree: true }).forEach((animation) => { + if (state.riskBattlePaused) animation.pause(); + else animation.play(); + }); +} + function render() { if (!state.data) return; clearP83BattleTimer(); @@ -1915,11 +1971,17 @@ function handleClick(event) { if (button.hasAttribute("data-p83-pause")) { if (state.riskBattleTick >= p83BattleEvents.length - 1) return; state.riskBattlePaused = !state.riskBattlePaused; - return render(); + clearP83BattleTimer(); + syncP83PlaybackSurface(); + if (!state.riskBattlePaused) scheduleP83BattleTimer(); + return; } if (button.dataset.p83Speed) { state.riskBattleSpeed = Number(button.dataset.p83Speed); - return render(); + clearP83BattleTimer(); + syncP83PlaybackSurface(); + if (!state.riskBattlePaused) scheduleP83BattleTimer(); + return; } if (button.hasAttribute("data-p83-inspect") || button.dataset.p83Event) { const requested = button.dataset.p83Event === undefined ? state.riskBattleTick : Number(button.dataset.p83Event); diff --git a/prototype/site/data/catalog.json b/prototype/site/data/catalog.json index c946dab4..ab2341ff 100644 --- a/prototype/site/data/catalog.json +++ b/prototype/site/data/catalog.json @@ -1,7 +1,7 @@ { "version": 7, - "status": "phase8-p8-3r1-dynamic-battle-prototype", - "authorityRevision": "phase8-p8-3r1-dynamic-horizontal-battle", + "status": "phase8-p8-3r2-frontline-behavior-prototype", + "authorityRevision": "phase8-p8-3r2-contact-rear-actions-replacement", "counts": { "screens": 50, "professions": 42, diff --git a/prototype/site/styles.css b/prototype/site/styles.css index 1e0f7a9f..89d2c32e 100644 --- a/prototype/site/styles.css +++ b/prototype/site/styles.css @@ -457,8 +457,12 @@ main { min-width: 0; padding: 24px; } .p83-direction { position: absolute; bottom: 5%; z-index: 4; color: #d8e4df; font-size: 5px; font-weight: 900; } .p83-direction.is-left { left: 3%; } .p83-direction.is-right { right: 3%; } -.p83-fighter { position: absolute; left: var(--fighter-x); bottom: 17%; z-index: 2; width: 10.5%; height: 61%; transform: translateX(-50%); transition: left .38s ease-out, filter .16s ease-out; } +.p83-fighter { position: absolute; left: var(--fighter-x); bottom: 17%; z-index: 2; width: 10.5%; height: 61%; transform: translateX(-50%); transition: filter .16s ease-out; } .p83-fighter.is-enemy { width: 11%; } +.p83-fighter.is-translating { animation: p83-formation-close calc(.82s / var(--p83-speed, 1)) cubic-bezier(.22,.76,.3,1) both; } +.p83-fighter.is-fallen { z-index: 1; pointer-events: none; } +.p83-fighter.is-fallen .p83-fighter-state { opacity: 0; } +.p83-fighter.is-fallen .p83-fighter-body { transform: translate(48%, 14%) rotate(-78deg); transform-origin: center bottom; filter: grayscale(.4) brightness(.62); } .p83-fighter-state { position: absolute; left: 50%; top: -1%; z-index: 4; width: 102%; padding: 3px 4px; color: var(--ink-950); background: rgba(243,246,242,.92); border-top: 3px solid var(--route-teal); transform: translateX(-50%); } .p83-fighter.is-enemy .p83-fighter-state { border-top-color: var(--danger-coral); } .p83-fighter-state span, .p83-fighter-state b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -469,38 +473,54 @@ main { min-width: 0; padding: 24px; } .p83-fighter.is-enemy .p83-fighter-state > i em { background: var(--danger-coral); } .p83-fighter-body { position: absolute; inset: 9% 0 0; display: grid; place-items: end center; transform-origin: center bottom; } .p83-fighter-art { width: 100%; height: 100%; object-fit: contain; object-position: center bottom; filter: drop-shadow(0 4px 4px rgba(4,11,14,.52)); } -.p83-motion-sprite { position: relative; width: 100%; height: 100%; display: block; } -.p83-motion-sprite img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: center bottom; opacity: 0; filter: drop-shadow(0 4px 4px rgba(4,11,14,.52)); animation-duration: var(--motion-cycle); animation-timing-function: steps(1,end); animation-delay: var(--motion-delay); animation-iteration-count: infinite; } +.p83-motion-sprite { position: relative; width: 100%; height: 100%; display: block; background-position: center bottom; background-repeat: no-repeat; background-size: contain; } +.p83-motion-sprite img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; object-position: center bottom; opacity: 0; filter: drop-shadow(0 4px 4px rgba(4,11,14,.52)); animation-duration: calc(var(--motion-cycle) / var(--p83-speed, 1)); animation-timing-function: steps(1,end); animation-delay: var(--motion-delay); animation-iteration-count: infinite; } +.p83-motion-sprite img:first-child { opacity: 1; } .p83-motion-sprite.frames-4 img { animation-name: p83-frame-4; } .p83-motion-sprite.frames-6 img { animation-name: p83-frame-6; } .p83-motion-sprite.frames-8 img { animation-name: p83-frame-8; } .p83-fighter.is-enemy .p83-motion-sprite img { transform: scaleX(-1); } +.p83-battle.is-paused .p83-motion-sprite, .p83-arena.is-frozen .p83-motion-sprite { background-image: var(--motion-poster); } +.p83-battle.is-paused .p83-fighter.is-enemy .p83-motion-sprite, .p83-arena.is-frozen .p83-fighter.is-enemy .p83-motion-sprite { transform: scaleX(-1); } +.p83-battle.is-paused .p83-motion-sprite img, .p83-arena.is-frozen .p83-motion-sprite img { visibility: hidden; } .p83-static-motion { width: 100%; height: 100%; display: grid; place-items: end center; transform-origin: center bottom; animation: p83-breathe calc(.95s / var(--p83-speed, 1)) ease-in-out infinite alternate; } .p83-static-motion.is-advance { animation-name: p83-stride; } .p83-static-motion.is-ranged { animation: p83-ranged calc(.5s / var(--p83-speed, 1)) ease-out infinite; } .p83-static-motion.is-sustain { animation: p83-cast calc(.75s / var(--p83-speed, 1)) ease-in-out infinite; } .p83-ground-shadow { position: absolute; left: 17%; right: 17%; bottom: -2px; height: 7px; z-index: -1; background: rgba(0,0,0,.42); border-radius: 50%; filter: blur(2px); } .p83-unknown-body { width: 54%; aspect-ratio: 1; display: grid; place-items: center; margin-bottom: 10%; color: var(--white); font-size: 30px; font-weight: 900; background: rgba(52,34,71,.82); border: 2px dashed #d7b9f6; border-radius: 50%; } -.p83-fighter.motion-advance.is-ally:nth-of-type(n) { animation: p83-close-ally calc(.9s / var(--p83-speed, 1)) ease-out infinite alternate; } -.p83-fighter.motion-advance.is-enemy { animation: p83-close-enemy calc(.9s / var(--p83-speed, 1)) ease-out infinite alternate; } .p83-fighter.motion-attack .p83-fighter-body { animation: p83-lunge calc(.46s / var(--p83-speed, 1)) ease-in-out infinite; } .p83-fighter.motion-hit .p83-fighter-body { animation: p83-hit calc(.38s / var(--p83-speed, 1)) ease-out infinite; } .p83-action-burst { position: absolute; z-index: 5; width: 10%; height: 28%; display: grid; place-items: center; animation: p83-burst calc(.65s / var(--p83-speed, 1)) ease-out infinite; } .p83-action-burst img { max-width: 100%; max-height: 100%; } -.p83-action-burst.is-slash { left: 59%; bottom: 27%; } -.p83-action-burst.is-guard { left: 47%; bottom: 29%; } -.p83-action-burst.is-ranged-hit { right: 10%; bottom: 34%; } -.p83-projectile { position: absolute; left: 46%; bottom: 51%; z-index: 6; width: 9%; height: 3px; background: #f9dd6b; box-shadow: 0 0 9px #f7f0ad; animation: p83-projectile calc(.68s / var(--p83-speed, 1)) linear infinite; } +.p83-action-burst.is-slash { left: 51%; bottom: 27%; } +.p83-action-burst.is-guard { left: 31%; bottom: 29%; } +.p83-action-burst.is-ranged-hit { right: 11%; bottom: 34%; } +.p83-projectile { position: absolute; left: 28%; bottom: 51%; z-index: 6; width: 9%; height: 3px; background: #f9dd6b; box-shadow: 0 0 9px #f7f0ad; animation: p83-projectile calc(.68s / var(--p83-speed, 1)) linear infinite; } .p83-projectile::after { content: ""; position: absolute; right: -3px; top: -3px; border-width: 4px 0 4px 7px; border-style: solid; border-color: transparent transparent transparent #fff3b0; } -.p83-heal-ring { position: absolute; left: 42%; bottom: 22%; z-index: 5; width: 14%; aspect-ratio: 1; border: 5px solid rgba(90,223,174,.72); border-radius: 50%; animation: p83-heal calc(.9s / var(--p83-speed, 1)) ease-out infinite; } +.p83-heal-link { position: absolute; left: 19%; bottom: 48%; z-index: 4; width: 18%; height: 3px; transform: rotate(-6deg); transform-origin: left; background: linear-gradient(90deg, rgba(90,223,174,.1), #80edbb); box-shadow: 0 0 8px #80edbb; } +.p83-heal-ring { position: absolute; left: 29%; bottom: 22%; z-index: 5; width: 14%; aspect-ratio: 1; border: 5px solid rgba(90,223,174,.72); border-radius: 50%; animation: p83-heal calc(.9s / var(--p83-speed, 1)) ease-out infinite; } .p83-float-number { position: absolute; z-index: 7; padding: 2px 4px; font-size: 8px; background: rgba(15,25,29,.82); animation: p83-float calc(.8s / var(--p83-speed, 1)) ease-out infinite; } -.p83-float-number.is-enemy-hit { left: 63%; top: 31%; color: #ffaaa6; } -.p83-float-number.is-guarded { left: 50%; top: 29%; color: #ffe27a; } +.p83-float-number.is-enemy-hit { left: 56%; top: 31%; color: #ffaaa6; } +.p83-float-number.is-guarded { left: 34%; top: 29%; color: #ffe27a; } .p83-float-number.is-rear-hit { right: 13%; top: 27%; color: #ffaaa6; } -.p83-float-number.is-heal { left: 49%; top: 34%; color: #95f3c7; } -.p83-equipment-trigger { position: absolute; left: 38%; top: 7%; z-index: 7; display: flex; align-items: center; gap: 4px; padding: 3px 5px; color: var(--ink-950); background: rgba(243,246,242,.94); border-left: 3px solid var(--work-gold); } +.p83-float-number.is-heal { left: 34%; top: 34%; color: #95f3c7; } +.p83-float-number.is-spell-hit { left: 43%; top: 25%; color: #d8b6ff; } +.p83-equipment-trigger { position: absolute; left: 39%; top: 7%; z-index: 7; display: flex; align-items: center; gap: 4px; padding: 3px 5px; color: var(--ink-950); background: rgba(243,246,242,.94); border-left: 3px solid var(--work-gold); } .p83-equipment-trigger img { width: 22px; height: 22px; object-fit: contain; } .p83-equipment-trigger span { font-size: 5px; font-weight: 900; } +.p83-contact-marker { position: absolute; left: var(--contact-x); top: 14%; bottom: 16%; z-index: 1; width: 1px; border-left: 2px dashed rgba(255,226,122,.82); } +.p83-contact-marker i { position: absolute; left: -7px; bottom: 0; width: 12px; height: 12px; border: 2px solid var(--work-gold); background: #17242a; transform: rotate(45deg); } +.p83-contact-marker span { position: absolute; left: 0; top: 0; padding: 3px 5px; color: var(--ink-950); font-size: 5px; font-weight: 900; white-space: nowrap; background: var(--work-gold); transform: translateX(-50%); } +.p83-contact-marker.is-replacement span { color: var(--white); background: var(--danger-coral); } +.p83-no-respawn { position: absolute; left: 48%; bottom: 19%; z-index: 7; display: grid; gap: 1px; padding: 3px 5px; color: var(--white); background: rgba(131,38,46,.9); border-left: 3px solid #ffaaa6; } +.p83-no-respawn b, .p83-no-respawn span { font-size: 5px; } +.p83-spell-demo { position: absolute; right: 2%; top: 6%; z-index: 7; display: grid; gap: 1px; padding: 4px 6px; color: var(--white); background: rgba(54,35,80,.92); border-left: 3px solid #cda8ff; } +.p83-spell-demo b, .p83-spell-demo span { font-size: 5px; } +.p83-spell-sigil { position: absolute; left: 77%; bottom: 39%; z-index: 7; width: 30px; height: 30px; display: grid; place-items: center; color: #f0d8ff; font-size: 19px; border: 2px solid #cda8ff; transform: rotate(45deg); animation: p83-spell-charge calc(.72s / var(--p83-speed, 1)) ease-in-out infinite; } +.p83-spell-projectile { position: absolute; left: 76%; bottom: 51%; z-index: 7; width: 20px; height: 10px; border-radius: 50%; background: #e6c5ff; box-shadow: 0 0 6px 3px #a768e8, 0 0 15px #d8b6ff; animation: p83-spell-bolt calc(.72s / var(--p83-speed, 1)) cubic-bezier(.4,0,.7,1) infinite; } +.p83-spell-projectile::after { content: ""; position: absolute; right: 12px; top: 3px; width: 28px; height: 4px; background: linear-gradient(90deg, transparent, #cda8ff); } +.p83-spell-impact { position: absolute; left: 43%; bottom: 37%; z-index: 7; width: 35px; height: 35px; border: 4px double #d8b6ff; transform: rotate(45deg); animation: p83-spell-impact calc(.72s / var(--p83-speed, 1)) ease-out infinite; } .p83-battle.speed-1, .p83-opening { --p83-speed: 1; } .p83-battle.speed-2 { --p83-speed: 2; } .p83-battle.speed-4 { --p83-speed: 4; } @@ -511,7 +531,7 @@ main { min-width: 0; padding: 24px; } .p83-battle-read article span { color: var(--route-teal); font-size: 5px; font-weight: 900; } .p83-battle-read article strong { font-size: 7px; } .p83-battle-read article p { margin: 2px 0 0; overflow: hidden; font-size: 5px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } -.p83-timeline { position: relative; display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px; padding-top: 5px; } +.p83-timeline { position: relative; display: grid; grid-template-columns: repeat(9, 1fr); gap: 2px; padding-top: 5px; } .p83-timeline::before, .p83-timeline > i { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--neutral-silver); } .p83-timeline > i { right: auto; width: var(--battle-progress); background: var(--work-gold); } .p83-timeline button { min-width: 0; padding: 3px 1px 0; color: #b7c4c6; font-size: 5px; text-align: center; background: transparent; border: 0; } @@ -541,14 +561,16 @@ main { min-width: 0; padding: 24px; } @keyframes p83-stride { from { transform: translate(-3%, 0) rotate(-1deg); } to { transform: translate(3%, -2%) rotate(1deg); } } @keyframes p83-ranged { 0%, 30%, 100% { transform: translateX(0) rotate(0); } 45% { transform: translateX(5%) rotate(-3deg); } 62% { transform: translateX(-2%) rotate(2deg); } } @keyframes p83-cast { 0%, 100% { transform: translateY(0); filter: drop-shadow(0 0 0 rgba(90,223,174,0)); } 50% { transform: translateY(-4%); filter: drop-shadow(0 0 8px rgba(90,223,174,.75)); } } -@keyframes p83-close-ally { from { transform: translateX(-5%); } to { transform: translateX(8%); } } -@keyframes p83-close-enemy { from { transform: translateX(5%); } to { transform: translateX(-8%); } } +@keyframes p83-formation-close { from { left: var(--fighter-from-x); } to { left: var(--fighter-x); } } @keyframes p83-lunge { 0%, 100% { transform: translateX(0); } 40% { transform: translateX(-8%); } 58% { transform: translateX(-18%); } } @keyframes p83-hit { 0%, 100% { transform: translateX(0) rotate(0); filter: brightness(1); } 32% { transform: translateX(10%) rotate(4deg); filter: brightness(1.8); } } @keyframes p83-burst { 0%, 100% { opacity: 0; transform: scale(.35) rotate(-10deg); } 38% { opacity: 1; transform: scale(1.1) rotate(3deg); } 72% { opacity: 0; transform: scale(1.35) rotate(8deg); } } -@keyframes p83-projectile { from { transform: translateX(0); opacity: 0; } 12% { opacity: 1; } 82% { opacity: 1; } to { transform: translateX(360%); opacity: 0; } } +@keyframes p83-projectile { from { transform: translateX(0); opacity: 0; } 12% { opacity: 1; } 82% { opacity: 1; } to { transform: translateX(570%); opacity: 0; } } @keyframes p83-heal { from { opacity: .9; transform: scale(.3); } to { opacity: 0; transform: scale(1.65); } } @keyframes p83-float { 0% { opacity: 0; transform: translateY(5px); } 24% { opacity: 1; } 100% { opacity: 0; transform: translateY(-16px); } } +@keyframes p83-spell-charge { 0%, 100% { opacity: .35; transform: rotate(45deg) scale(.72); } 50% { opacity: 1; transform: rotate(135deg) scale(1.05); } } +@keyframes p83-spell-bolt { 0%, 18% { opacity: 0; transform: translate(0,0) scale(.7); } 28% { opacity: 1; } 78% { opacity: 1; } 100% { opacity: 0; transform: translate(-1550%, 18px) scale(1.2); } } +@keyframes p83-spell-impact { 0%, 65% { opacity: 0; transform: rotate(45deg) scale(.25); } 78% { opacity: 1; transform: rotate(100deg) scale(1); } 100% { opacity: 0; transform: rotate(145deg) scale(1.6); } } /* Opening company */ .opening-composition, .creation-review, .contract-composition { height: 100%; padding: 2.2% 3.5% 5%; } diff --git a/prototype/site/verify-p8-3.js b/prototype/site/verify-p8-3.js index bc06cf17..c38aed14 100644 --- a/prototype/site/verify-p8-3.js +++ b/prototype/site/verify-p8-3.js @@ -35,6 +35,30 @@ async function stageAndActionFit(page, selector, label) { assert.ok(metrics.aLeft >= metrics.sLeft && metrics.aRight <= metrics.sRight && metrics.aTop >= metrics.sTop && metrics.aBottom <= metrics.sBottom, `${label} action: ${JSON.stringify(metrics)}`); } +async function fighterFact(page, selector) { + return page.locator(selector).evaluate((node) => { + const rect = node.getBoundingClientRect(); + return { + id: node.dataset.fighterId || `enemy-${node.dataset.enemyIndex}`, + x: Number(node.dataset.x), + hp: Number(node.dataset.hp), + motion: node.dataset.motionState, + role: node.dataset.lineRole || null, + alive: node.dataset.alive === undefined ? null : node.dataset.alive === "true", + left: rect.left, + top: rect.top, + }; + }); +} + +async function waitForBattleEvent(page, eventIndex) { + await page.waitForFunction((index) => document.querySelector(".p83-arena")?.dataset.battleEvent === String(index), eventIndex); +} + +async function animationTime(page, selector) { + return page.locator(selector).evaluate((node) => node.getAnimations()[0]?.currentTime ?? null); +} + async function selectReviewCommit(page) { assert.equal(await page.locator("[data-p83-risk]").count(), 3, "three stable risks"); assert.equal(await page.locator("[data-p83-risk].is-selected").count(), 0, "no preselected risk"); @@ -67,30 +91,44 @@ async function openAndObserve(page, evidence) { await page.locator("[data-p83-open-battle]").click(); assert.match(await page.locator(".p83-opening").textContent(), /战斗中不会复活/); assert.match(await page.locator(".p83-opening").textContent(), /后备队员不会自动补位/); - assert.match(await page.locator(".p83-opening").textContent(), /0号位是最右侧暴露前排/); + assert.match(await page.locator(".p83-opening").textContent(), /已保存站位原样入场/); + assert.match(await page.locator(".p83-opening").textContent(), /近战接敌停步、后排治疗与远射/); assert.equal(await page.locator(".p83-arena .p83-fighter.is-ally").count(), 4, "four allies share one arena"); assert.equal(await page.locator(".p83-arena .p83-fighter.is-enemy").count(), 3, "chosen risk enemies share one arena"); assert.equal(await page.locator(".p83-opening-lines").count(), 0, "opening no longer uses tall unit cards"); - assert.equal(await page.locator('.p83-arena[data-battle-event="0"]').count(), 1, "opening line starts in advance state"); + assert.equal(await page.locator('.p83-arena[data-formation-phase="entry"]').count(), 1, "opening starts before formation movement"); + assert.deepEqual(await page.locator(".p83-fighter.is-ally").evaluateAll((nodes) => nodes.map((node) => Number(node.dataset.x))), [7, 17, 27, 37], "accepted Company enters from the left"); + assert.deepEqual(await page.locator(".p83-fighter.is-enemy").evaluateAll((nodes) => nodes.map((node) => Number(node.dataset.x))), [72, 82, 92], "enemy formation enters from the right"); evidence.push(await screenshot(page, "desktop-battle-opening.png")); await page.locator("[data-p83-start]").click(); - assert.equal(await page.locator('[data-motion-state="advance"]').count() >= 2, true, "both forces visibly advance"); + assert.equal(await page.locator('.p83-arena[data-formation-phase="closing"]').count(), 1, "both formations enter closing phase"); + assert.equal(await page.locator(".p83-fighter.is-translating").count(), 7, "every visible footprint translates during entry"); + const movingFront = page.locator('[data-fighter-id="OFF-03"]'); + const movingStart = await movingFront.evaluate((node) => node.getBoundingClientRect().left); const animatedFrame = page.locator('.p83-motion-sprite img').first(); const frameBefore = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState })); await page.waitForTimeout(220); + const movingAfter = await movingFront.evaluate((node) => node.getBoundingClientRect().left); const frameAfter = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState })); + assert.ok(movingAfter > movingStart + 20, `allied front advances right: ${movingStart} -> ${movingAfter}`); assert.equal(frameBefore.playState, "running"); assert.equal(frameAfter.playState, "running"); assert.notEqual(frameBefore.opacity, frameAfter.opacity, "frame animation advances while playing"); await page.locator("[data-p83-pause]").click(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); const pausedEvents = await page.locator("[data-p83-event].is-reached").count(); - const pausedFrameBefore = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState })); + const pausedPositionBefore = await movingFront.evaluate((node) => node.getBoundingClientRect().left); + const pausedSprite = page.locator('.p83-motion-sprite').first(); + const pausedFrameBefore = await pausedSprite.evaluate((node) => ({ backgroundImage: getComputedStyle(node).backgroundImage, playState: node.querySelector("img").getAnimations()[0]?.playState })); await page.waitForTimeout(1100); - const pausedFrameAfter = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState })); + const pausedPositionAfter = await movingFront.evaluate((node) => node.getBoundingClientRect().left); + const pausedFrameAfter = await pausedSprite.evaluate((node) => ({ backgroundImage: getComputedStyle(node).backgroundImage, playState: node.querySelector("img").getAnimations()[0]?.playState })); assert.equal(await page.locator("[data-p83-event].is-reached").count(), pausedEvents, "Pause freezes event progress"); + assert.ok(Math.abs(pausedPositionAfter - pausedPositionBefore) < 0.1, `Pause freezes formation position: ${pausedPositionBefore} -> ${pausedPositionAfter}`); assert.deepEqual(pausedFrameAfter, pausedFrameBefore, "Pause freezes sprite-frame motion"); assert.equal(pausedFrameAfter.playState, "paused"); + assert.match(pausedFrameAfter.backgroundImage, /frame-0\.png/, "Pause renders a stable motion poster"); const duration1x = Number.parseFloat(await animatedFrame.evaluate((node) => getComputedStyle(node).animationDuration)); await page.locator('[data-p83-speed="4"]').click(); @@ -98,15 +136,30 @@ async function openAndObserve(page, evidence) { const duration4x = Number.parseFloat(await page.locator('.p83-motion-sprite img').first().evaluate((node) => getComputedStyle(node).animationDuration)); assert.ok(duration4x < duration1x / 3.5, `4x shortens motion cycle: ${duration1x} -> ${duration4x}`); await page.locator("[data-p83-pause]").click(); - await page.waitForFunction(() => document.querySelector('.p83-arena')?.dataset.battleEvent === "2"); + await waitForBattleEvent(page, 1); + await page.locator("[data-p83-pause]").click(); + assert.equal(await page.locator('.p83-contact-marker[data-contact-x="52.5"]').count(), 1, "one visible contact boundary"); + const contactWayrunner = await fighterFact(page, '[data-fighter-id="OFF-03"]'); + const contactEnemy = await fighterFact(page, '[data-enemy-index="0"]'); + const contactHearthguard = await fighterFact(page, '[data-fighter-id="MKT-R1-01"]'); + const contactKindhand = await fighterFact(page, '[data-fighter-id="OFF-04"]'); + const contactFarstring = await fighterFact(page, '[data-fighter-id="MKT-R1-02"]'); + assert.equal(contactWayrunner.x, 46.5); + assert.equal(contactEnemy.x, 58.2); + assert.ok(contactHearthguard.x < contactWayrunner.x, "Hearthguard remains behind the foremost melee footprint"); + assert.ok(contactKindhand.x < contactHearthguard.x && contactFarstring.x < contactHearthguard.x, "healer and archer preserve rear spacing"); + evidence.push(await screenshot(page, "desktop-battle-contact.png")); + await page.locator("[data-p83-pause]").click(); + await waitForBattleEvent(page, 2); await page.locator("[data-p83-pause]").click(); const progressBeforeInspect = await page.locator("[data-p83-event].is-reached").count(); - assert.equal(await page.locator('.p83-action-burst.is-guard').count(), 1, "Guard/intercept is visible on event 2"); - assert.equal(await page.locator('[data-fighter-id="MKT-R1-01"][data-motion-state="protect"]').count(), 1, "Hearthguard uses accepted protect frames"); - evidence.push(await screenshot(page, "desktop-battle-guard.png")); - await page.locator('[data-p83-event="1"]').click(); + assert.equal(await page.locator('[data-fighter-id="OFF-03"][data-motion-state="attack"][data-contacted="true"]').count(), 1, "Wayrunner attacks only after contact"); + assert.equal(await page.locator('[data-fighter-id="MKT-R1-01"][data-motion-state="attack"][data-contacted="true"]').count(), 1, "Hearthguard attacks only after contact"); + assert.equal((await fighterFact(page, '[data-enemy-index="0"]')).hp, 72, "post-contact melee lowers enemy front HP"); + evidence.push(await screenshot(page, "desktop-battle-melee.png")); + await page.locator('[data-p83-event="2"]').click(); assert.equal(await page.locator(".p83-cause-sheet").count(), 1); - assert.match(await page.locator(".p83-cause-sheet").textContent(), /开阵系带/); + assert.match(await page.locator(".p83-cause-sheet").textContent(), /近战只在接敌后出手/); assert.match(await page.locator(".p83-cause-sheet").textContent(), /不会改变进度或结果/); assert.equal(await page.locator("[data-p83-event].is-reached").count(), progressBeforeInspect, "inspect does not rewind progress"); evidence.push(await screenshot(page, "desktop-causal-inspect.png")); @@ -114,20 +167,52 @@ async function openAndObserve(page, evidence) { assert.match(await page.locator(".p83-battle-rules").textContent(), /已暂停/); await page.locator("[data-p83-pause]").click(); - await page.waitForFunction(() => document.querySelector('.p83-arena')?.dataset.battleEvent === "3"); + await waitForBattleEvent(page, 3); await page.locator("[data-p83-pause]").click(); - assert.equal(await page.locator('.p83-projectile').count(), 1, "ranged projectile is visible on event 3"); - assert.equal(await page.locator('[data-fighter-id="MKT-R1-02"][data-motion-state="ranged"]').count(), 1, "Farstring performs ranged motion"); + assert.equal(await page.locator('.p83-action-burst.is-guard').count(), 1, "Guard/intercept is visible"); + assert.equal(await page.locator('[data-fighter-id="MKT-R1-01"][data-motion-state="protect"]').count(), 1, "Hearthguard uses accepted protect frames"); + assert.equal((await fighterFact(page, '[data-fighter-id="MKT-R1-01"]')).hp, 72, "Hearthguard is visibly injured before healing"); + evidence.push(await screenshot(page, "desktop-battle-guard.png")); + await page.locator("[data-p83-pause]").click(); + await waitForBattleEvent(page, 4); + await page.locator("[data-p83-pause]").click(); + assert.equal(await page.locator('[data-projectile-kind="arrow"][data-source="MKT-R1-02"][data-target="enemy-2"]').count(), 1, "Farstring launches one legal rear projectile"); + assert.equal(await page.locator('[data-fighter-id="MKT-R1-02"][data-motion-state="ranged"][data-line-role="rear"]').count(), 1, "Farstring fires from the rear"); + assert.equal((await fighterFact(page, '[data-enemy-index="2"]')).hp, 72, "ranged target HP decreases"); + const projectileTimeBefore = await animationTime(page, '[data-projectile-kind="arrow"]'); + await page.waitForTimeout(300); + const projectileTimeAfter = await animationTime(page, '[data-projectile-kind="arrow"]'); + assert.equal(projectileTimeAfter, projectileTimeBefore, "Pause freezes projectile travel"); evidence.push(await screenshot(page, "desktop-battle-projectile.png")); await page.locator("[data-p83-pause]").click(); - await page.waitForFunction(() => document.querySelector('.p83-arena')?.dataset.battleEvent === "4"); + await waitForBattleEvent(page, 5); await page.locator("[data-p83-pause]").click(); - assert.equal(await page.locator('.p83-heal-ring').count(), 1, "sustain feedback is visible on event 4"); - assert.equal(await page.locator('[data-fighter-id="OFF-04"][data-motion-state="sustain"]').count(), 1, "Kindhand performs sustain motion"); + assert.equal(await page.locator('[data-heal-target="MKT-R1-01"]').count(), 1, "heal visibly targets the injured ally"); + assert.equal(await page.locator('[data-fighter-id="OFF-04"][data-motion-state="sustain"][data-line-role="rear"]').count(), 1, "Kindhand heals from the rear"); + assert.equal((await fighterFact(page, '[data-fighter-id="MKT-R1-01"]')).hp, 88, "healing raises Hearthguard HP from 72 to 88"); evidence.push(await screenshot(page, "desktop-battle-sustain.png")); await page.locator("[data-p83-pause]").click(); + await waitForBattleEvent(page, 6); + await page.locator("[data-p83-pause]").click(); + assert.equal(await page.locator('[data-demo-role="PF-B06"]').count(), 1, "Riftcaller language belongs to enemy rear demo"); + assert.equal(await page.locator('[data-projectile-kind="spell"]').count(), 1, "spell bolt is distinct from the arrow"); + assert.equal(await page.locator('[data-projectile-kind="arrow"]').count(), 0, "spell event does not reuse the arrow language"); + assert.match(await page.locator(".p83-spell-demo").textContent(), /不是我方第五人/); + evidence.push(await screenshot(page, "desktop-battle-spell.png")); + await page.locator("[data-p83-pause]").click(); + await waitForBattleEvent(page, 7); + await page.locator("[data-p83-pause]").click(); + const fallen = await fighterFact(page, '[data-enemy-index="0"]'); + const replacement = await fighterFact(page, '[data-enemy-index="1"]'); + assert.equal(fallen.hp, 0); + assert.equal(fallen.alive, false, "fallen front stays down"); + assert.equal(replacement.x, fallen.x, "next living front advances to the same contact footprint"); + assert.equal(await page.locator(".p83-no-respawn").count(), 1); + assert.equal(await page.locator(".p83-fighter.is-ally").count(), 4, "no fifth ally or Reserve entry"); + evidence.push(await screenshot(page, "desktop-battle-replacement.png")); + await page.locator("[data-p83-pause]").click(); await page.waitForSelector("[data-p83-finish]"); - assert.equal(await page.locator("[data-p83-event].is-reached").count(), 6, "deterministic six-event timeline"); + assert.equal(await page.locator("[data-p83-event].is-reached").count(), 9, "deterministic nine-event behavior chain"); evidence.push(await screenshot(page, "desktop-battle-resolved-timeline.png")); await page.locator("[data-p83-finish]").click(); assert.match(await page.locator(".p83-resolved").textContent(), /P8\.4 尚未开启/); @@ -215,7 +300,7 @@ async function main() { } finally { await browser.close(); } - console.log("ABG_P8_3R1_BROWSER_OK risks=3 preselected=0 commits=1 arena=continuous_line allies=4 entry=advance motion=idle,advance,attack,ranged,guard,hit,sustain events=6 pause_freeze=event_and_frame speeds=1,2,4 inspect_readonly=1 result_mutation=0 responsive=desktop,phone_landscape,phone_portrait bundle=offline"); + console.log("ABG_P8_3R2_BROWSER_OK risks=3 preselected=0 commits=1 arena=continuous_line allies=4 entry=two_sided contact=single_stop melee=post_contact rear=heal,arrow spell=enemy_pf_b06 replacement=next_living no_respawn=1 reserve_entry=0 events=9 pause_freeze=position,event,frame,projectile speeds=1,2,4 inspect_readonly=1 result_mutation=0 responsive=desktop,phone_landscape,phone_portrait bundle=offline"); console.log(`evidence=${evidence.join(",")}`); } diff --git a/tools/build_gallery_bundle.py b/tools/build_gallery_bundle.py index 204c0046..dc281e80 100644 --- a/tools/build_gallery_bundle.py +++ b/tools/build_gallery_bundle.py @@ -185,8 +185,8 @@ def build_catalog() -> dict[str, object]: payload: dict[str, object] = { "version": 7, - "status": "phase8-p8-3r1-dynamic-battle-prototype", - "authorityRevision": "phase8-p8-3r1-dynamic-horizontal-battle", + "status": "phase8-p8-3r2-frontline-behavior-prototype", + "authorityRevision": "phase8-p8-3r2-contact-rear-actions-replacement", "counts": expected, **entries, }