diff --git a/docs/city-stay-immersion-upgrade-report.md b/docs/city-stay-immersion-upgrade-report.md new file mode 100644 index 0000000..18fd8e7 --- /dev/null +++ b/docs/city-stay-immersion-upgrade-report.md @@ -0,0 +1,142 @@ +# City-stay immersion upgrade + +Date: 2026-07-27 + +## Why this batch + +The playable Xuzhou, Xinye, and Chengdu stays already connected information, +equipment, and companion choices to later battles, but their presentation +still used code-drawn buildings and tactical unit sheets. This made the +between-battle experience visibly regress after the hand-painted prologue and +Guangzong exploration scenes. + +The upgrade follows four established design lessons: + +- [GDC's wayfinding and storytelling session](https://www.gdcvault.com/play/1022117/level-design-in-a-day) + recommends conveying information through architecture and readable spatial + navigation. +- [GDC's environmental storytelling framework](https://www.gdcvault.com/play/1012696/What-Happened-Here-Environmental) + treats props, materials, lighting, composition, and world reaction as parts + of the narrative rather than decoration. +- [The Tacoma non-linear story-discovery workshop](https://www.gdcvault.com/play/1025178/Level-Design-Workshop-Designing-for) + connects self-directed exploration order to story discovery. +- [The cognitive-map talk](https://www.gdcvault.com/play/1027206/Stop-Getting-Lost-Make-Cognitive) + emphasizes memorable landmarks and navigable spaces when navigation is not + intended to be the challenge. + +Applied here, every city retains the same learnable crossroads grammar while +its government office, market, companion residence, materials, climate, and +recovery state communicate a different campaign moment. + +## Runtime assets + +| Stay | Background | Named companion SD sheet | +| --- | --- | --- | +| Xuzhou | `src/assets/images/exploration/city-xuzhou-stay.webp` | `characters/exploration-mi-zhu.webp` | +| Xinye | `src/assets/images/exploration/city-xinye-stay.webp` | `characters/exploration-zhuge-liang.webp` | +| Chengdu | `src/assets/images/exploration/city-chengdu-stay.webp` | `characters/exploration-huang-quan.webp` | + +The three backgrounds are original 1920x1080 opaque WebP assets. The three +named companion sheets are original 3072x768 transparent WebP sheets using the +shared four-direction, eight-idle-frame, and eight-walk-frame contract. + +## Built-in image generation prompt set + +All six assets used the built-in image generation workflow. No commercial-game +screenshots, logos, character art, or copyrighted layouts were used. + +### Common city background prompt + +> Original premium late-Eastern-Han walkable city-stay environment for a +> desktop historical RPG, fixed elevated three-quarter top-down camera, broad +> readable north-south avenue and east-west crossroads, government office at +> upper-left, market at upper-right, companion residence at lower-right, and a +> clear bottom-center road back to camp. Preserve the top, right, and bottom +> fixed-HUD regions as dark low-detail safe zones. Rich painterly realistic +> materials and strong landmarks. No people, animals, readable text, labels, +> UI, logo, watermark, modern object, copied architecture, or copyrighted game +> screen. + +The Xuzhou variant shows a recently relieved provincial city with cart tracks, +sealed grain crates, and merchant storehouses at warm late afternoon. The +Xinye variant shows a greener autumn frontier town with timber palisades, +scout forecourts, bamboo, and an adviser residence before Bowang. The Chengdu +variant shows a wealthier Yi Province capital after surrender, with damp +stonework, organized granaries, petition space, and restrained new +administration. + +### Common named SD turnaround prompt + +> Create one completely original 2.5-head-tall painterly cel-shaded SD +> overworld character. Show the same identity exactly three times in equal +> horizontal columns: front/south, screen-right/east with face and toes +> pointing toward the right edge, and back/north. Keep identical +> scale and lighting, a neutral walking-ready stance, and the full body and +> feet. Use a perfectly flat solid `#ff00ff` removable chroma-key background +> with no floor, shadow, gradient, texture, text, label, grid, logo, watermark, +> extra character, or copied commercial-game likeness. + +The character variants preserve only identity and palette from the project's +own portrait assets: + +- Mi Zhu: cream, navy, and restrained gold merchant-official robes. +- Zhuge Liang: ivory and dark-jade scholar robes with a rolled strategist cap. +- Huang Quan: dark navy and muted bronze Yi Province official robes. + +The generated turnarounds were chroma-cleaned with the installed image +generation helper and assembled with +`scripts/build-exploration-character-sheet.py`. Only optimized runtime assets +are tracked. + +## Interaction and presentation scope + +- Replace code-drawn city blocks with the dedicated original background for + each city while retaining the proven interaction layout and collision + contract. +- Use exploration SD sheets for Liu Bei, clerks, smiths, and named companions; + tactical sprites remain battle-only. +- Use face portraits in dialogue rather than shrinking a full-body field + sprite into the dialogue window. +- Let a pointer click on an activity target complete the old-RPG interaction + loop: walk to a valid nearby position, face the target, and interact without + a second click. +- Preserve keyboard movement and direct interaction, and preserve the + information, purchase, equipment, resonance, save, and later-battle payoff + state. + +## Verification + +Verification is performed at an explicit 1920x1080 CSS viewport, 100% browser +zoom, and DPR 1. WebGL is the required renderer and Canvas is the compatibility +renderer. + +The completed checks cover: + +- all three 1920x1080 background dimensions, opacity, and the combined + 2.87 MiB exploration-background budget; +- all thirteen exploration sheets at 3072x768 with alpha and the shared + four-direction animation contract; +- CityStayScene type and data integrity, including equipment offers, visit + rewards, resonance links, soundscapes, and lookup helpers; +- real pointer travel to information, market, companion, and exit targets, + automatic one-time interaction, authored NPC positions, low-frame-rate + arrival clamping, collision detours, and distance-gated keyboard interaction; +- face-only dialogue portraits, intentional portrait-free narrator lines, + market purchase, resonance choice, reward idempotence, save/reload + restoration, optional early exit, and next-battle briefing payoff; +- Xuzhou, Xinye, and Chengdu in both Canvas and WebGL at the required desktop + baseline, with representative screenshots written as + `dist/verification-city-*-map.png`, + `dist/verification-city-*-information.png`, and + `dist/verification-city-*-dialogue.png`; +- the complete static, build, loader-lifecycle, interaction, accessibility, + battle keyboard, prologue, pursuit, relief, third-return, third-camp, + audiovisual, release-candidate, and performance gates. + +The review also corrected four edge cases before release: legacy invisible +blockers from the former vector city, a portrait crop whose origin followed +the uncropped source image, east/west rows reversed in the three new SD +sheets, and pointer movement that could oscillate across a destination at a +50 ms frame interval. A CampScene browser fixture was also made to wait for +the third-camp exploration button's rendered interactive state instead of +reading it during scene-restart asset loading. diff --git a/docs/visual-asset-prologue-exploration-report.md b/docs/visual-asset-prologue-exploration-report.md index 367a7f6..db85a21 100644 --- a/docs/visual-asset-prologue-exploration-report.md +++ b/docs/visual-asset-prologue-exploration-report.md @@ -12,11 +12,13 @@ files. | Zhuo village and militia camp | `src/assets/images/exploration/prologue-village.webp`, `prologue-militia-camp.webp` | 1920×1080 opaque WebP | | Second-victory northern village and ferry | `src/assets/images/exploration/second-pursuit-village-ferry.webp` | 1920×1080 opaque WebP | | Third-victory Guangzong forward sortie camp | `src/assets/images/exploration/third-guangzong-sortie-camp.webp` | 1920×1080 opaque WebP | +| Xuzhou, Xinye, and Chengdu city stays | `src/assets/images/exploration/city-*-stay.webp` | 1920×1080 opaque WebP | | Exploration SD characters | `src/assets/images/exploration/characters/exploration-*.webp` | 3072×768 transparent WebP sprite sheets | | First-victory camp Jian Yong | `src/assets/images/exploration/characters/exploration-jian-yong.webp` | 3072×768 transparent WebP sprite sheet | +| City-stay companions | `exploration-mi-zhu.webp`, `exploration-zhuge-liang.webp`, `exploration-huang-quan.webp` | 3072×768 transparent WebP sprite sheets | | Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP | -The four backgrounds reserve the exact top, right, and bottom HUD regions used +The seven backgrounds reserve the exact top, right, and bottom HUD regions used by the 1920×1080 desktop layout. Each playable portion was aligned to the existing collision and interaction coordinate system before export. @@ -66,6 +68,22 @@ weapon area on the right. Its final built-in generation request was: > people, text, UI, logos, modern objects, copied characters, or copyrighted > game art. +The three city-stay variants use one learnable crossroads layout while changing +their architectural story: Xuzhou is a recently relieved provincial city, +Xinye is a vulnerable autumn frontier town preparing Zhuge Liang's first +command, and Chengdu is a wealthier Yi Province capital settling into a new +administration. Their shared request was: + +> Original premium late-Eastern-Han walkable city-stay environment for a +> desktop historical RPG, fixed elevated three-quarter top-down camera, broad +> readable north-south avenue and east-west crossroads, government office at +> upper-left, market at upper-right, companion residence at lower-right, and a +> clear bottom-center road back to camp. Preserve the top, right, and bottom +> fixed-HUD regions as dark low-detail safe zones. Rich painterly realistic +> materials and strong landmarks. No people, animals, readable text, labels, +> UI, logo, watermark, modern object, copied architecture, or copyrighted game +> screen. + The generated masters were converted to opaque 1920×1080 WebP runtime assets with the project background builder or an equivalent loss-controlled FFmpeg conversion; only the optimized runtime assets are tracked. @@ -116,6 +134,14 @@ The generated turnaround was chroma-cleaned and assembled by `scripts/build-exploration-character-sheet.py`; only the optimized transparent runtime sheet is shipped. +The Xuzhou, Xinye, and Chengdu stays add dedicated SD turnarounds for Mi Zhu, +Zhuge Liang, and Huang Quan. Their project portraits were used only as identity +and palette references. Each final request retained the common three-view, +flat-magenta-background contract while specifying the character's own robes, +headwear, age, and silhouette. The same chroma-cleaning and sprite-sheet +assembly workflow was used, so tactical unit sheets are no longer shown in +city exploration. + ## Optimization and verification - Backgrounds are limited to 2 MiB each and 6 MiB combined. diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md index 482f297..37b20bd 100644 --- a/docs/visual-asset-quality-audit.md +++ b/docs/visual-asset-quality-audit.md @@ -10,8 +10,8 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. - Flagged battle map rows with repaint reports: 3 - Battle map source pixel density range: 60.0-96.0 px/tile - Story source backgrounds: 136 -- Story page background references: 466 -- Flagged story background review rows: 17 +- Story page background references: 469 +- Flagged story background review rows: 16 ## Main Findings @@ -53,10 +53,10 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | 2 | `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 10 | 1672x941 | 1.8187 | repeat story background | `fortySeventhBattleIntroPages/forty-seventh-zhao-yun-vanguard`
`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`
`fortyNinthBattleVictoryPages/forty-ninth-victory-second-capture` | | 2 | `src/assets/images/story/25-yizhou-luo-road.png` | 10 | 1672x941 | 2.4004 | repeat story background | `twentySeventhBattleIntroPages/twenty-seventh-yizhou-request`
`twentySeventhBattleIntroPages/twenty-seventh-gate-warning`
`twentySeventhBattleVictoryPages/twenty-seventh-victory-road-open` | | 2 | `src/assets/images/story/37-yizhou-luo-road-ambush.png` | 9 | 1672x941 | 1.7313 | repeat story background | `twentyThirdBattleIntroPages/twenty-third-zhuge-plan`
`twentyFourthBattleIntroPages/twenty-fourth-yi-ji-rumor`
`twentySeventhBattleVictoryPages/twenty-seventh-next-fu-pass` | -| 2 | `src/assets/images/story/72-northern-mountain-war-council.png` | 8 | 1672x941 | 1.3639 | repeat story background | `thirtyFifthBattleIntroPages/thirty-fifth-hanzhong-front`
`thirtySixthBattleIntroPages/thirty-sixth-dingjun-mountain`
`thirtySixthBattleIntroPages/thirty-sixth-western-cavalry` | | 2 | `src/assets/images/story/12-yizhou-mountain-pass.png` | 8 | 1672x941 | 1.6969 | repeat story background | `twentyThirdBattleIntroPages/twenty-third-ma-liang-rumor`
`twentyThirdBattleVictoryPages/twenty-third-victory-southern-road`
`twentyThirdBattleVictoryPages/twenty-third-next-commandery` | | 2 | `src/assets/images/story/28-nanzhong-seven-captures.png` | 8 | 1672x941 | 2.4106 | repeat story background | `fortySeventhBattleIntroPages/forty-seventh-zhuge-policy`
`fortySeventhBattleVictoryPages/forty-seventh-next-nanman`
`fortyEighthBattleVictoryPages/forty-eighth-next-capture` | | 2 | `src/assets/images/story/30-maicheng-isolation.png` | 2 | 1672x941 | 1.1976 | low PNG byte density | `fortyFourthBattleIntroPages/forty-fourth-lu-meng-seals-roads`
`fortyFourthBattleVictoryPages/forty-fourth-lu-meng-tightens` | +| 1 | `src/assets/images/story/02-liu-bei-resolve.png` | 0 | 1672x941 | 1.3671 | unused source background | none | ## Battle Map Inventory @@ -148,7 +148,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/102-yiling-baidi-lantern-vigil.png` | 2 | 1672x941 | 2.26 | none | | `src/assets/images/story/103-weishui-northbank-frozen-ferry.png` | 3 | 1672x941 | 2.75 | none | | `src/assets/images/story/104-lucheng-pursuit-dust-road.png` | 1 | 1672x941 | 3.01 | none | -| `src/assets/images/story/105-qishan-renewed-snow-relay.png` | 2 | 1672x941 | 2.81 | none | +| `src/assets/images/story/105-qishan-renewed-snow-relay.png` | 5 | 1672x941 | 2.81 | none | | `src/assets/images/story/106-hanzhong-rain-supply-trench.png` | 3 | 1672x941 | 2.49 | none | | `src/assets/images/story/107-maicheng-isolation-abandoned-gate.png` | 1 | 1672x941 | 2.45 | none | | `src/assets/images/story/108-yiling-fire-forest-corridor.png` | 2 | 1672x941 | 2.44 | none | @@ -163,7 +163,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/116-yiling-fire-attack-river-ashes.png` | 2 | 1672x941 | 2.26 | none | | `src/assets/images/story/117-baidi-entrustment-river-watch.png` | 1 | 1672x941 | 2.10 | none | | `src/assets/images/story/118-tianshui-front-hidden-letter.png` | 2 | 1672x941 | 2.35 | none | -| `src/assets/images/story/119-hanzhong-rain-mountain-shrine.png` | 1 | 1672x941 | 2.67 | none | +| `src/assets/images/story/119-hanzhong-rain-mountain-shrine.png` | 2 | 1672x941 | 2.67 | none | | `src/assets/images/story/12-yizhou-mountain-pass.png` | 8 | 1672x941 | 2.55 | repeat story background | | `src/assets/images/story/120-maicheng-isolation-snow-bridge.png` | 3 | 1672x941 | 2.29 | none | | `src/assets/images/story/121-wudu-yinping-cloud-signal.png` | 3 | 1672x941 | 2.68 | none | @@ -177,7 +177,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/129-three-heroes-rain-shelter.png` | 1 | 1672x941 | 2.31 | none | | `src/assets/images/story/13-northern-expedition.png` | 11 | 1672x941 | 2.35 | repeat story background | | `src/assets/images/story/130-lucheng-pursuit-ravine-signal.png` | 1 | 1672x941 | 2.72 | none | -| `src/assets/images/story/131-weishui-camps-frozen-watch.png` | 2 | 1672x941 | 2.22 | none | +| `src/assets/images/story/131-weishui-camps-frozen-watch.png` | 5 | 1672x941 | 2.22 | none | | `src/assets/images/story/132-weishui-northbank-siege-line.png` | 1 | 1672x941 | 2.55 | none | | `src/assets/images/story/133-yiling-fire-attack-smoke-crossing.png` | 2 | 1672x941 | 2.37 | none | | `src/assets/images/story/134-chengdu-surrender-sealed-gate.png` | 1 | 1672x941 | 2.50 | none | @@ -192,7 +192,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/21-hanzhong-rain-defense.png` | 2 | 1672x941 | 2.25 | none | | `src/assets/images/story/22-lucheng-pursuit.png` | 2 | 1672x941 | 2.03 | none | | `src/assets/images/story/23-weishui-camps.png` | 1 | 1672x941 | 2.31 | none | -| `src/assets/images/story/24-weishui-northbank.png` | 2 | 1672x941 | 2.22 | none | +| `src/assets/images/story/24-weishui-northbank.png` | 1 | 1672x941 | 2.22 | none | | `src/assets/images/story/25-yizhou-luo-road.png` | 10 | 1672x941 | 3.60 | repeat story background | | `src/assets/images/story/26-chengdu-surrender.png` | 2 | 1672x941 | 3.64 | none | | `src/assets/images/story/27-jingzhou-crisis.png` | 5 | 1672x941 | 3.23 | none | @@ -211,7 +211,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 10 | 1672x941 | 2.73 | repeat story background | | `src/assets/images/story/41-wandering-night-camp.png` | 5 | 1672x941 | 2.55 | none | | `src/assets/images/story/42-wandering-river-crossing.png` | 6 | 1672x941 | 2.60 | none | -| `src/assets/images/story/43-northern-qishan-snow-march.png` | 5 | 1672x941 | 3.03 | none | +| `src/assets/images/story/43-northern-qishan-snow-march.png` | 4 | 1672x941 | 3.03 | none | | `src/assets/images/story/44-northern-wei-fortress.png` | 5 | 1672x941 | 2.93 | none | | `src/assets/images/story/45-yizhou-council-at-river.png` | 2 | 1672x941 | 2.89 | none | | `src/assets/images/story/46-yizhou-mountain-gate.png` | 2 | 1672x941 | 3.01 | none | @@ -230,8 +230,8 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/59-yiling-baidi-mountain-retreat.png` | 3 | 1672x941 | 2.84 | none | | `src/assets/images/story/60-maicheng-isolation-snow-road.png` | 1 | 1672x941 | 2.74 | none | | `src/assets/images/story/61-yiling-fire-attack-riverbank.png` | 1 | 1672x941 | 2.60 | none | -| `src/assets/images/story/62-hanzhong-rain-night-watch.png` | 3 | 1672x941 | 2.55 | none | -| `src/assets/images/story/63-weishui-northbank-cavalry-screen.png` | 2 | 1672x941 | 2.74 | none | +| `src/assets/images/story/62-hanzhong-rain-night-watch.png` | 2 | 1672x941 | 2.55 | none | +| `src/assets/images/story/63-weishui-northbank-cavalry-screen.png` | 1 | 1672x941 | 2.74 | none | | `src/assets/images/story/64-nanzhong-forest-council.png` | 1 | 1672x941 | 2.73 | none | | `src/assets/images/story/65-yizhou-luo-road-cliff-fortress.png` | 5 | 1672x941 | 2.36 | none | | `src/assets/images/story/66-yizhou-luo-road-mountain-scouts.png` | 11 | 1672x941 | 2.59 | repeat story background | @@ -240,7 +240,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/69-wandering-market-shelter.png` | 6 | 1672x941 | 2.31 | none | | `src/assets/images/story/70-wandering-cold-mountain-pass.png` | 7 | 1672x941 | 2.10 | none | | `src/assets/images/story/71-northern-supply-valley.png` | 3 | 1672x941 | 2.65 | none | -| `src/assets/images/story/72-northern-mountain-war-council.png` | 8 | 1672x941 | 2.05 | repeat story background | +| `src/assets/images/story/72-northern-mountain-war-council.png` | 7 | 1672x941 | 2.05 | none | | `src/assets/images/story/73-red-cliffs-chain-smoke.png` | 3 | 1672x941 | 2.20 | none | | `src/assets/images/story/74-wolong-river-strategy.png` | 2 | 1672x941 | 2.29 | none | | `src/assets/images/story/75-jingzhou-crisis-river-watchtowers.png` | 2 | 1672x941 | 2.38 | none | @@ -263,7 +263,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`. | `src/assets/images/story/92-northern-supply-depot.png` | 4 | 1672x941 | 2.73 | none | | `src/assets/images/story/93-yizhou-mountain-pass-command-post.png` | 4 | 1672x941 | 2.70 | none | | `src/assets/images/story/94-red-cliffs-dawn-aftermath.png` | 3 | 1672x941 | 2.43 | none | -| `src/assets/images/story/95-yellow-pursuit-rural-crossroads.png` | 2 | 1672x941 | 2.95 | none | +| `src/assets/images/story/95-yellow-pursuit-rural-crossroads.png` | 3 | 1672x941 | 2.95 | none | | `src/assets/images/story/96-wolong-autumn-study.png` | 6 | 1672x941 | 2.56 | none | | `src/assets/images/story/97-fan-castle-flood-prison-camp.png` | 4 | 1672x941 | 2.15 | none | | `src/assets/images/story/98-jingzhou-crisis-secret-letter.png` | 4 | 1672x941 | 2.22 | none | diff --git a/scripts/build-exploration-character-sheet.py b/scripts/build-exploration-character-sheet.py index 15c17a5..f2d74b2 100644 --- a/scripts/build-exploration-character-sheet.py +++ b/scripts/build-exploration-character-sheet.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Build a four-direction exploration sprite sheet from a three-view turnaround. -The input is expected to contain transparent front, right, and back views in -three equal horizontal columns. The output matches the runtime exploration -contract: 192px frames, four rows (south/east/north/west), and sixteen frames -per row (eight idle followed by eight walk frames). +The input is expected to contain transparent front, screen-right-facing, and +back views in three equal horizontal columns. In the middle view, the face and +feet must point toward the right edge of the image; an anatomical-right profile +that points screen-left must be mirrored before building. The output matches +the runtime exploration contract: 192px frames, four rows +(south/east/north/west), and sixteen frames per row (eight idle followed by +eight walk frames). """ from __future__ import annotations diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs index 01a5e71..734a173 100644 --- a/scripts/verify-city-stay-browser.mjs +++ b/scripts/verify-city-stay-browser.mjs @@ -42,8 +42,15 @@ const cityCases = [ } ]; const [xuzhouCase, xinyeCase, chengduCase] = cityCases; +const rendererTypes = { + canvas: 1, + webgl: 2 +}; +const baseTargetUrl = + process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'; const targetUrl = withDebugOptions( - process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/' + baseTargetUrl, + 'canvas' ); let serverProcess; @@ -125,9 +132,16 @@ try { `Expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}` ); + await verifyRendererVisualInteractionCoverage( + browser, + baseTargetUrl, + 'webgl' + ); + console.log( `Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + - `DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard and world-pointer movement, distance-gated interaction, ` + + `DPR ${desktopBrowserDeviceScaleFactor} in Canvas and WebGL: Camp gateway entry, high-resolution city backgrounds, ` + + 'SD exploration characters, portrait dialogue, real pointer navigation and automatic interaction, distance-gated interaction, ' + 'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' + 'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.' ); @@ -138,15 +152,133 @@ try { } } -function withDebugOptions(url) { +function withDebugOptions(url, renderer) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); - parsed.searchParams.set('renderer', 'canvas'); + parsed.searchParams.set('renderer', renderer); return parsed.toString(); } -async function openCleanCamp(page) { - await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); +async function verifyRendererVisualInteractionCoverage( + browserInstance, + url, + renderer +) { + const context = await browserInstance.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + await openCleanCamp(page, withDebugOptions(url, renderer)); + await assertDesktopViewport(page); + + for (const [index, cityCase] of cityCases.entries()) { + if (index > 0) { + await openCampScene(page); + } + await seedCityStay(page, cityCase); + await restartCamp(page, cityCase.cityStayId); + + const gateway = await waitForCityGateway( + page, + cityCase.cityStayId + ); + verifyGatewayLayout(gateway); + let city = await enterCityFromGateway( + page, + gateway, + cityCase.cityStayId + ); + verifyExplorationLayout(city, cityCase.cityStayId); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-${renderer}-map.png` + ); + + await pointerInteractWith(page, 'information', { + expectTravel: true + }); + city = await readCity(page); + verifyActiveDialoguePortrait( + city, + `${renderer} ${cityCase.cityStayId} information dialogue` + ); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-${renderer}-information-pointer.png` + ); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === + false + ); + + await pointerInteractWith(page, 'market', { + expectTravel: true + }); + city = await readCity(page); + verifyShopLayout(city); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false + ); + + await pointerInteractWith(page, 'dialogue', { + expectTravel: true + }); + city = await readCity(page); + verifyActiveDialoguePortrait( + city, + `${renderer} ${cityCase.cityStayId} companion dialogue` + ); + await captureStableScreenshot( + page, + `dist/verification-city-${cityCase.cityStayId}-${renderer}-companion-pointer.png` + ); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === + false + ); + assert.equal( + (await readCity(page)).progress.completed, + 0, + `${renderer} visual interaction coverage must not complete optional city activities.` + ); + } + + assert.deepEqual( + pageErrors, + [], + `${renderer}: expected no browser page errors: ${JSON.stringify(pageErrors.slice(-8))}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes('The AudioContext was not allowed to start') + ), + [], + `${renderer}: expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}` + ); + } finally { + await context.close(); + } +} + +async function openCleanCamp(page, url = targetUrl) { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); @@ -385,6 +517,7 @@ async function waitForCityReady(page, expectedCityStayId) { { cause: error } ); } + await assertDesktopViewport(page); await page.waitForTimeout(380); return readCity(page); } @@ -405,10 +538,62 @@ function verifyExplorationLayout(city, expectedCityStayId) { ['dialogue', 'information', 'market'] ); assert.equal(city.requiredTexturesReady, true); + assert.deepEqual( + { + textureKey: city.background?.textureKey, + ready: city.background?.ready, + sourceWidth: city.background?.sourceWidth, + sourceHeight: city.background?.sourceHeight, + fallback: city.background?.fallback + }, + { + textureKey: `city-${expectedCityStayId}-stay-background`, + ready: true, + sourceWidth: desktopBrowserViewport.width, + sourceHeight: desktopBrowserViewport.height, + fallback: false + }, + `${expectedCityStayId} must render its authored 1920x1080 city background texture.` + ); + assertBoundsCoverViewport( + city.background?.bounds, + `${expectedCityStayId} background` + ); + assert( + city.player?.textureKey?.startsWith('exploration-'), + `${expectedCityStayId} player must use an exploration-* SD texture: ${JSON.stringify(city.player)}` + ); + assert( + city.player?.animationKey?.startsWith(`${city.player.textureKey}-`), + `${expectedCityStayId} player animation must use its SD texture: ${JSON.stringify(city.player)}` + ); assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`); assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`); city.actors.forEach((actor) => { + assert( + actor.textureKey?.startsWith('exploration-'), + `${expectedCityStayId} actor ${actor.id} must use an exploration-* SD texture: ${JSON.stringify(actor)}` + ); + assert( + actor.animationKey?.startsWith(`${actor.textureKey}-`), + `${expectedCityStayId} actor ${actor.id} animation must use its SD texture: ${JSON.stringify(actor)}` + ); + assert.equal( + actor.moved, + false, + `${expectedCityStayId} actor ${actor.id} must remain at its authored position.` + ); + assert.equal( + actor.x, + actor.initialX, + `${expectedCityStayId} actor ${actor.id} X must match its authored position.` + ); + assert.equal( + actor.y, + actor.initialY, + `${expectedCityStayId} actor ${actor.id} Y must match its authored position.` + ); assertBoundsInsideViewport(actor.bounds, `${expectedCityStayId} actor ${actor.id}`); }); city.blockers.forEach((blocker, index) => { @@ -512,10 +697,14 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) { assert(!before.campaign.completedCampVisits.includes(cityCase.informationId)); const inventoryBefore = { ...before.campaign.inventory }; - await interactWith(page, 'information'); + await pointerInteractWith(page, 'information', { expectTravel: true }); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); + verifyActiveDialoguePortrait( + await readCity(page), + `${cityCase.cityStayId} information dialogue` + ); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-information.png` @@ -543,10 +732,14 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) { 'The information completion ID must be unique.' ); - await interactWith(page, 'information'); + await pointerInteractWith(page, 'information'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); + verifyActiveDialoguePortrait( + await readCity(page), + `${cityCase.cityStayId} repeated information dialogue` + ); await advanceCityDialogueUntilClosed(page); const afterRepeated = await readCity(page); assert.deepEqual( @@ -571,7 +764,7 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) { } async function verifyMarketPurchase(page, screenshotPath) { - await interactWith(page, 'market'); + await pointerInteractWith(page, 'market', { expectTravel: true }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); const before = await readCity(page); verifyShopLayout(before); @@ -627,10 +820,14 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) { const bondBefore = campaignBefore.bonds.find((bond) => bond.id === cityCase.bondId); assert(bondBefore, `Expected seeded bond ${cityCase.bondId}.`); - await interactWith(page, 'dialogue'); + await pointerInteractWith(page, 'dialogue', { expectTravel: true }); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); + verifyActiveDialoguePortrait( + await readCity(page), + `${cityCase.cityStayId} companion dialogue` + ); await advanceDialogueUntilChoice(page); const choiceState = await readCity(page); verifyChoiceLayout(choiceState); @@ -696,6 +893,83 @@ function verifyChoiceLayout(city) { ); } +function verifyActiveDialoguePortrait(city, label) { + assert.equal( + city.dialogue?.active, + true, + `${label}: dialogue must be active.` + ); + assert.equal( + city.dialogue.portraitVisible, + true, + `${label}: the face portrait must be visible.` + ); + assert( + city.dialogue.portraitTextureKey?.startsWith('portrait-'), + `${label}: expected a portrait-* face texture, received ${JSON.stringify(city.dialogue.portraitTextureKey)}.` + ); + assertBoundsInsideViewport( + city.dialogue.portraitBounds, + `${label} face portrait` + ); + assertBoundsInsideViewport(city.dialogue.bounds, `${label} panel`); +} + +function verifyDialoguePortraitPolicy(city, label) { + const portraitlessSpeakers = new Set([ + '공명', + '체류 기록', + '길잡이' + ]); + if (!portraitlessSpeakers.has(city.dialogue?.speaker)) { + verifyActiveDialoguePortrait(city, label); + return; + } + assert.equal( + city.dialogue?.active, + true, + `${label}: narrator dialogue must be active.` + ); + assert.equal( + city.dialogue.portraitVisible, + false, + `${label}: narrator line must leave the face portrait hidden.` + ); + assert.equal( + city.dialogue.portraitTextureKey, + null, + `${label}: narrator line must not expose a stale portrait texture.` + ); + assertBoundsInsideViewport( + city.dialogue.bounds, + `${label} panel` + ); +} + +function assertActorsRemainAuthored(expectedActors, city, label) { + assert.equal( + city.actors.length, + expectedActors.length, + `${label}: actor count changed while the player moved.` + ); + expectedActors.forEach((expected) => { + const actual = city.actors.find((actor) => actor.id === expected.id); + assert(actual, `${label}: actor ${expected.id} disappeared.`); + assert.deepEqual( + { + id: actual.id, + x: actual.x, + y: actual.y, + initialX: actual.initialX, + initialY: actual.initialY, + moved: actual.moved + }, + expected, + `${label}: actor ${expected.id} must not teleport or drift.` + ); + }); +} + async function reloadAndContinueCityStay(page, expectedCityStayId) { const beforeReload = await readCity(page); assert.equal(beforeReload.activeCityStayId, expectedCityStayId); @@ -798,11 +1072,15 @@ async function verifyAdditionalCityLayout(page, cityCase, options) { `dist/verification-city-${cityCase.cityStayId}-map.png` ); - await interactWith(page, 'information'); + await pointerInteractWith(page, 'information', { expectTravel: true }); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); city = await readCity(page); + verifyActiveDialoguePortrait( + city, + `${cityCase.cityStayId} information dialogue` + ); assertBoundsInsideViewport(city.dialogue.bounds, `${cityCase.cityStayId} information dialogue`); await captureStableScreenshot( page, @@ -814,7 +1092,7 @@ async function verifyAdditionalCityLayout(page, cityCase, options) { )); assert.equal((await readCity(page)).progress.informationComplete, false); - await interactWith(page, 'market'); + await pointerInteractWith(page, 'market', { expectTravel: true }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); city = await readCity(page); verifyShopLayout(city); @@ -825,10 +1103,14 @@ async function verifyAdditionalCityLayout(page, cityCase, options) { await page.keyboard.press('Escape'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false); - await interactWith(page, 'dialogue'); + await pointerInteractWith(page, 'dialogue', { expectTravel: true }); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); + verifyActiveDialoguePortrait( + await readCity(page), + `${cityCase.cityStayId} companion dialogue` + ); await advanceDialogueUntilChoice(page); city = await readCity(page); verifyChoiceLayout(city); @@ -851,7 +1133,7 @@ async function verifyIncompleteCityExit(page, cityCase) { assert.equal(before.progress.completed, 0); assert.equal(before.progress.exitUnlocked, true); - await interactWith(page, 'exit'); + await pointerInteractWith(page, 'exit', { expectTravel: true }); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); @@ -874,13 +1156,159 @@ async function verifyIncompleteCityExit(page, cityCase) { assert(!campaign.completedCampDialogues.includes(cityCase.dialogueId)); } -async function interactWith(page, targetIdOrKind) { - const result = await page.evaluate((target) => { - const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); - return scene?.debugInteractWith?.(target) ?? false; - }, targetIdOrKind); - assert.equal(result, true, `Expected CityStayScene interaction with ${targetIdOrKind}.`); - await page.waitForTimeout(90); +async function pointerInteractWith( + page, + targetIdOrKind, + { expectTravel = false } = {} +) { + const before = await readCity(page); + assert.equal(before.dialogue.active, false); + assert.equal(before.shop.open, false); + assert.equal(before.choice.open, false); + const actor = before.actors.find( + (candidate) => + candidate.id === targetIdOrKind || candidate.kind === targetIdOrKind + ); + const target = actor ?? ( + targetIdOrKind === 'exit' || targetIdOrKind === before.exit?.id + ? { + ...before.exit, + id: before.exit.id, + kind: 'exit', + bounds: before.exit.bounds ?? null + } + : null + ); + assert( + target, + `Expected a pointer target for ${targetIdOrKind}: ${JSON.stringify({ + actors: before.actors.map(({ id, kind }) => ({ id, kind })), + exit: before.exit + })}` + ); + assert( + before.movement?.autoInteraction, + `CityStayScene must expose automatic pointer interaction state: ${JSON.stringify(before.movement)}` + ); + const actorPositions = before.actors.map( + ({ id, x, y, initialX, initialY, moved }) => ({ + id, + x, + y, + initialX, + initialY, + moved + }) + ); + assertActorsRemainAuthored(actorPositions, before, `${before.cityStayId} before ${target.id}`); + + const beforeTriggerCount = + before.movement.autoInteraction.triggeredCount; + const playerStart = { x: before.player.x, y: before.player.y }; + if (target.bounds) { + await clickSceneBounds(page, 'CityStayScene', target.bounds); + } else { + await clickScenePoint(page, 'CityStayScene', { + x: target.x, + y: target.y + }); + } + + let routeObserved = false; + let maxPlayerDisplacement = 0; + let after; + for (let attempt = 0; attempt < 600; attempt += 1) { + after = await readCity(page); + assertActorsRemainAuthored( + actorPositions, + after, + `${before.cityStayId} while approaching ${target.id}` + ); + const autoInteraction = after.movement?.autoInteraction; + if ( + autoInteraction?.pending === true && + autoInteraction.targetId === target.id + ) { + routeObserved = true; + } + if ( + after.movement?.targetId === target.id || + after.movement?.waypoints?.length > 0 + ) { + routeObserved = true; + } + maxPlayerDisplacement = Math.max( + maxPlayerDisplacement, + Math.hypot( + after.player.x - playerStart.x, + after.player.y - playerStart.y + ) + ); + const interactionOpened = + target.kind === 'market' + ? after.shop.open === true + : target.kind === 'exit' + ? after.dialogue.active === true && + after.dialogue.sourceTargetId === target.id + : after.dialogue.active === true && + after.dialogue.sourceTargetId === target.id; + if (interactionOpened) { + break; + } + await page.waitForTimeout(50); + } + + assert( + after, + `Expected CityStayScene state after clicking ${target.id}.` + ); + const interactionOpened = + target.kind === 'market' + ? after.shop.open === true + : after.dialogue.active === true && + after.dialogue.sourceTargetId === target.id; + assert( + interactionOpened, + `Pointer click did not automatically interact with ${target.id}: ${JSON.stringify(after)}` + ); + if (expectTravel) { + assert( + routeObserved, + `Expected pointer navigation state while approaching ${target.id}: ${JSON.stringify(after.movement)}` + ); + assert( + maxPlayerDisplacement >= 40, + `Expected Liu Bei to visibly walk toward ${target.id}: ${JSON.stringify({ + playerStart, + playerEnd: after.player, + maxPlayerDisplacement + })}` + ); + } + assert.equal( + after.movement.autoInteraction.triggeredCount, + beforeTriggerCount + 1, + `Pointer interaction with ${target.id} must trigger exactly once.` + ); + assert.equal( + after.movement.autoInteraction.lastTriggeredTargetId, + target.id, + `Pointer interaction must report ${target.id} as its final automatic target.` + ); + if (actor) { + const finalActor = after.actors.find( + (candidate) => candidate.id === actor.id + ); + assert( + finalActor?.distance <= after.interaction.radius + 2, + `Automatic movement must finish inside ${actor.id}'s interaction radius: ${JSON.stringify(finalActor)}` + ); + } + assertActorsRemainAuthored( + actorPositions, + after, + `${before.cityStayId} after ${target.id}` + ); } async function advanceCityDialogueUntilClosed(page) { @@ -889,6 +1317,10 @@ async function advanceCityDialogueUntilClosed(page) { if (!city?.dialogue?.active) { return; } + verifyDialoguePortraitPolicy( + city, + `${city.cityStayId} dialogue line ${city.dialogue.lineIndex + 1}` + ); await page.mouse.click(960, 850); await page.waitForTimeout(130); } @@ -904,6 +1336,10 @@ async function advanceDialogueUntilChoice(page) { if (!city?.dialogue?.active) { throw new Error(`Dialogue closed without opening a choice: ${JSON.stringify(city)}`); } + verifyDialoguePortraitPolicy( + city, + `${city.cityStayId} resonance line ${city.dialogue.lineIndex + 1}` + ); await page.mouse.click(960, 850); await page.waitForTimeout(130); } @@ -989,6 +1425,18 @@ function assertBoundsInsideViewport(bounds, label) { ); } +function assertBoundsCoverViewport(bounds, label) { + assert(bounds, `${label}: bounds are required.`); + const epsilon = 0.01; + assert( + Math.abs(bounds.x) <= epsilon && + Math.abs(bounds.y) <= epsilon && + Math.abs(bounds.width - desktopBrowserViewport.width) <= epsilon && + Math.abs(bounds.height - desktopBrowserViewport.height) <= epsilon, + `${label}: expected a full 1920x1080 display, received ${JSON.stringify(bounds)}` + ); +} + function assertBoundsInside(bounds, container, label) { assert(bounds, `${label}: bounds are required.`); const epsilon = 0.01; @@ -1018,6 +1466,7 @@ async function assertDesktopViewport(page) { height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 1, + rendererType: window.__HEROS_GAME__?.renderer?.type ?? null, canvas: canvas ? { width: canvas.width, @@ -1041,6 +1490,17 @@ async function assertDesktopViewport(page) { assert.equal(viewport.canvas?.clientHeight, desktopBrowserViewport.height); assert.equal(viewport.canvas?.bounds?.width, desktopBrowserViewport.width); assert.equal(viewport.canvas?.bounds?.height, desktopBrowserViewport.height); + const requestedRenderer = + new URL(page.url()).searchParams.get('renderer') ?? 'canvas'; + assert( + requestedRenderer in rendererTypes, + `Unknown requested renderer "${requestedRenderer}".` + ); + assert.equal( + viewport.rendererType, + rendererTypes[requestedRenderer], + `${requestedRenderer}: Phaser must use the requested renderer.` + ); } async function clickSceneBounds(page, sceneKey, bounds) { diff --git a/scripts/verify-city-stay-data.mjs b/scripts/verify-city-stay-data.mjs index bf7224d..02614de 100644 --- a/scripts/verify-city-stay-data.mjs +++ b/scripts/verify-city-stay-data.mjs @@ -22,7 +22,9 @@ try { cityStayExplorationProfiles, getCityStayExplorationProfile } = await server.ssrLoadModule('/src/game/data/cityStayExploration.ts'); - const { unitBaseSheetAssetInfo } = await server.ssrLoadModule('/src/game/data/unitAssets.ts'); + const { explorationCharacterAssetInfo } = await server.ssrLoadModule( + '/src/game/data/explorationCharacterAssets.ts' + ); const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog); @@ -36,7 +38,7 @@ try { cityStayDefinitions, cityStayExplorationProfiles, getCityStayExplorationProfile, - unitBaseSheetAssetInfo, + explorationCharacterAssetInfo, musicTracks, ambienceTracks ); @@ -246,7 +248,7 @@ function validateExplorationProfiles( definitions, profiles, getProfile, - unitBaseSheetAssetInfo, + explorationCharacterAssetInfo, musicTracks, ambienceTracks ) { @@ -366,14 +368,14 @@ function validateExplorationProfiles( if (!knownDirections.has(actor?.direction)) { errors.push(`${actorContext}.direction must be north/east/south/west`); } - if (!unitBaseSheetAssetInfo(actor?.textureKey)) { - errors.push(`${actorContext}: unknown unit texture "${actor?.textureKey}"`); + if (!explorationCharacterAssetInfo(actor?.textureKey)) { + errors.push(`${actorContext}: unknown exploration character texture "${actor?.textureKey}"`); } }); } - if (!unitBaseSheetAssetInfo('unit-liu-bei')) { - errors.push(`${context}: player texture "unit-liu-bei" is missing`); + if (!explorationCharacterAssetInfo('exploration-liu-bei')) { + errors.push(`${context}: player texture "exploration-liu-bei" is missing`); } if (!Array.isArray(profile.buildings) || profile.buildings.length !== 3) { errors.push(`${context}.buildings must contain exactly 3 entries`); diff --git a/scripts/verify-exploration-character-asset-data.mjs b/scripts/verify-exploration-character-asset-data.mjs index 9699455..4bbef79 100644 --- a/scripts/verify-exploration-character-asset-data.mjs +++ b/scripts/verify-exploration-character-asset-data.mjs @@ -21,11 +21,14 @@ const expectedBaseMappings = { 'unit-shu-infantry': 'exploration-shu-infantry' }; const expectedNamedTextureFallbacks = { + 'exploration-huang-quan': 'unit-shu-officer', + 'exploration-jian-yong': 'unit-shu-officer', + 'exploration-mi-zhu': 'unit-shu-officer', + 'exploration-zhuge-liang': 'unit-shu-officer', 'exploration-zhuo-recruiting-clerk': 'unit-shu-officer', 'exploration-zhuo-villager': 'unit-shu-infantry', 'exploration-zhuo-quartermaster': 'unit-shu-officer', - 'exploration-zou-jing': 'unit-shu-officer', - 'exploration-jian-yong': 'unit-shu-officer' + 'exploration-zou-jing': 'unit-shu-officer' }; const expectedSceneNpcTextureKeys = [ { @@ -136,17 +139,22 @@ assert.deepEqual( deployedFiles.forEach((fileName) => { const path = join(characterDirectory, fileName); - const dimensions = webpDimensions(path); + const inspection = inspectWebp(path); assert.equal( - dimensions.width, + inspection.width, expectedWidth, `${path}: expected ${expectedWidth}px width for 16 frames per direction.` ); assert.equal( - dimensions.height, + inspection.height, expectedHeight, `${path}: expected ${expectedHeight}px height for four direction rows.` ); + assert.equal( + inspection.hasAlpha, + true, + `${path}: exploration character sheets must preserve transparent backgrounds.` + ); }); console.log( @@ -172,7 +180,7 @@ function readStringMap(source, mappingName) { return mappings; } -function webpDimensions(path) { +function inspectWebp(path) { const bytes = readFileSync(path); assert( bytes.subarray(0, 4).toString('ascii') === 'RIFF' && @@ -181,6 +189,8 @@ function webpDimensions(path) { ); let offset = 12; + let dimensions; + let hasAlpha = false; while (offset + 8 <= bytes.length) { const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); const chunkSize = bytes.readUInt32LE(offset + 4); @@ -191,20 +201,22 @@ function webpDimensions(path) { ); if (chunkType === 'VP8X') { - return { + dimensions = { width: bytes.readUIntLE(payloadOffset + 4, 3) + 1, height: bytes.readUIntLE(payloadOffset + 7, 3) + 1 }; - } - if (chunkType === 'VP8L') { + hasAlpha ||= (bytes[payloadOffset] & 0x10) !== 0; + } else if (chunkType === 'ALPH') { + hasAlpha = true; + } else if (chunkType === 'VP8L') { const bits = bytes.readUInt32LE(payloadOffset + 1); - return { + dimensions ??= { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; - } - if (chunkType === 'VP8 ') { - return { + hasAlpha ||= (bits & 0x10000000) !== 0; + } else if (chunkType === 'VP8 ') { + dimensions ??= { width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff, height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff }; @@ -213,5 +225,6 @@ function webpDimensions(path) { offset = payloadOffset + chunkSize + (chunkSize % 2); } - throw new Error(`${path}: could not read WebP dimensions.`); + assert(dimensions, `${path}: could not read WebP dimensions.`); + return { ...dimensions, hasAlpha }; } diff --git a/scripts/verify-prologue-exploration-asset-data.mjs b/scripts/verify-prologue-exploration-asset-data.mjs index 934775c..abe14fd 100644 --- a/scripts/verify-prologue-exploration-asset-data.mjs +++ b/scripts/verify-prologue-exploration-asset-data.mjs @@ -6,7 +6,10 @@ const expectedAssets = [ 'prologue-village.webp', 'prologue-militia-camp.webp', 'second-pursuit-village-ferry.webp', - 'third-guangzong-sortie-camp.webp' + 'third-guangzong-sortie-camp.webp', + 'city-xuzhou-stay.webp', + 'city-xinye-stay.webp', + 'city-chengdu-stay.webp' ]; const expectedWidth = 1920; const expectedHeight = 1080; diff --git a/scripts/verify-third-battle-priority-return-browser.mjs b/scripts/verify-third-battle-priority-return-browser.mjs index dd8be15..88a43e4 100644 --- a/scripts/verify-third-battle-priority-return-browser.mjs +++ b/scripts/verify-third-battle-priority-return-browser.mjs @@ -177,6 +177,17 @@ async function verifyFixture(browser, baseUrl, fixture) { scene.activeTab = 'visit'; scene.scene.restart(); }); + await page.waitForFunction(() => { + const exploration = + window.__HEROS_DEBUG__?.camp?.() + ?.thirdCampExploration; + return ( + exploration?.available === true && + exploration?.selected === true && + exploration?.explorationInteractive === true && + Boolean(exploration.explorationButtonBounds) + ); + }, undefined, { timeout: 90000 }); let camp = await waitForPriorityReturn( page, fixture, diff --git a/src/assets/images/exploration/characters/exploration-huang-quan.webp b/src/assets/images/exploration/characters/exploration-huang-quan.webp new file mode 100644 index 0000000..e88e1a6 Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-huang-quan.webp differ diff --git a/src/assets/images/exploration/characters/exploration-mi-zhu.webp b/src/assets/images/exploration/characters/exploration-mi-zhu.webp new file mode 100644 index 0000000..5f8a6bb Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-mi-zhu.webp differ diff --git a/src/assets/images/exploration/characters/exploration-zhuge-liang.webp b/src/assets/images/exploration/characters/exploration-zhuge-liang.webp new file mode 100644 index 0000000..ae38a6a Binary files /dev/null and b/src/assets/images/exploration/characters/exploration-zhuge-liang.webp differ diff --git a/src/assets/images/exploration/city-chengdu-stay.webp b/src/assets/images/exploration/city-chengdu-stay.webp new file mode 100644 index 0000000..46ec1ab Binary files /dev/null and b/src/assets/images/exploration/city-chengdu-stay.webp differ diff --git a/src/assets/images/exploration/city-xinye-stay.webp b/src/assets/images/exploration/city-xinye-stay.webp new file mode 100644 index 0000000..0d149aa Binary files /dev/null and b/src/assets/images/exploration/city-xinye-stay.webp differ diff --git a/src/assets/images/exploration/city-xuzhou-stay.webp b/src/assets/images/exploration/city-xuzhou-stay.webp new file mode 100644 index 0000000..a1865f9 Binary files /dev/null and b/src/assets/images/exploration/city-xuzhou-stay.webp differ diff --git a/src/game/data/cityStayExploration.ts b/src/game/data/cityStayExploration.ts index 156e494..afdd599 100644 --- a/src/game/data/cityStayExploration.ts +++ b/src/game/data/cityStayExploration.ts @@ -72,7 +72,7 @@ export const cityStayExplorationProfiles: Readonly +> = { + xuzhou: { + textureKey: 'city-xuzhou-stay-background', + url: xuzhouBackgroundUrl + }, + xinye: { + textureKey: 'city-xinye-stay-background', + url: xinyeBackgroundUrl + }, + chengdu: { + textureKey: 'city-chengdu-stay-background', + url: chengduBackgroundUrl + } +}; type CityStaySceneData = { cityStayId?: CityStayId; @@ -58,10 +99,12 @@ type CityStaySceneData = { type CityActorView = { definition: CityStayExplorationActor; + textureKey: ExplorationCharacterTextureKey; sprite: Phaser.GameObjects.Sprite; shadow: Phaser.GameObjects.Ellipse; nameplate: Phaser.GameObjects.Text; marker: Phaser.GameObjects.Text; + initialPosition: { x: number; y: number }; }; type CityInteractionTarget = @@ -83,7 +126,7 @@ type CityInteractionTarget = type CityDialogueLine = { speaker: string; text: string; - textureKey?: string; + portraitKey?: string; }; type DialogueState = { @@ -114,10 +157,12 @@ export class CityStayScene extends Phaser.Scene { private cityStay: CityStayDefinition = cityFallback; private profile: CityStayExplorationProfile = cityStayExplorationProfiles[cityFallback.id]; private campaign?: CampaignState; + private backgroundImage?: Phaser.GameObjects.Image; + private backgroundFallback = false; private player?: Phaser.GameObjects.Sprite; private playerShadow?: Phaser.GameObjects.Ellipse; private playerNameplate?: Phaser.GameObjects.Text; - private playerDirection: UnitDirection = 'north'; + private playerDirection: ExplorationCharacterDirection = 'north'; private playerMoving = false; private actors = new Map(); private blockers: Phaser.Geom.Rectangle[] = []; @@ -129,7 +174,8 @@ export class CityStayScene extends Phaser.Scene { private informationCard?: Phaser.GameObjects.Rectangle; private dialogueCard?: Phaser.GameObjects.Rectangle; private dialoguePanel?: Phaser.GameObjects.Container; - private dialogueAvatar?: Phaser.GameObjects.Sprite; + private dialoguePortraitFrame?: Phaser.GameObjects.Rectangle; + private dialoguePortrait?: Phaser.GameObjects.Image; private dialogueNameText?: Phaser.GameObjects.Text; private dialogueBodyText?: Phaser.GameObjects.Text; private dialogueProgressText?: Phaser.GameObjects.Text; @@ -139,17 +185,25 @@ export class CityStayScene extends Phaser.Scene { private shopNotice = ''; private choicePanel?: Phaser.GameObjects.Container; private choiceViews: ChoiceView[] = []; - private loadingOverlay?: Phaser.GameObjects.Container; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; private explorationInput?: ExplorationInputController; private moveTarget?: Phaser.Math.Vector2; + private moveWaypoints: Phaser.Math.Vector2[] = []; + private moveTargetId?: CityInteractionTarget['id']; + private lastNavigationTargetId?: CityInteractionTarget['id']; + private navigationReplanAttempts = 0; + private navigationDetourCount = 0; + private autoInteractionPending = false; + private autoInteractionTriggeredCount = 0; + private lastAutoInteractionTargetId?: CityInteractionTarget['id']; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; + private ownedPresentationTextureKeys = new Set(); constructor() { super('CityStayScene'); @@ -169,13 +223,47 @@ export class CityStayScene extends Phaser.Scene { this.profile = getCityStayExplorationProfile(this.cityStay.id); } + preload() { + explorationCharacterAssetInfos( + this.characterTextureKeys() + ).forEach((asset) => { + if (!this.textures.exists(asset.key)) { + this.load.spritesheet(asset.key, asset.url, { + frameWidth: asset.frameWidth, + frameHeight: asset.frameHeight + }); + } + }); + const background = this.currentBackground(); + if (!this.textures.exists(background.textureKey)) { + this.ownedPresentationTextureKeys.add(background.textureKey); + this.load.image(background.textureKey, background.url); + } + this.currentPortraitEntries().forEach(({ textureKey, url }) => { + if (!this.textures.exists(textureKey)) { + this.ownedPresentationTextureKeys.add(textureKey); + this.load.image(textureKey, url); + } + }); + } + create() { this.resetRuntimeState(); this.markCityStayActive(); this.drawCity(); this.drawHud(); - this.createLoadingOverlay(); this.setupInput(); + ensureExplorationCharacterAnimations( + this, + this.characterTextureKeys() + ); + this.createActors(); + this.createDialoguePanel(); + this.ready = true; + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.refreshProgressHud(); + this.refreshActorMarkers(); + this.refreshInteractionPrompt(); soundDirector.playSoundscape({ musicKey: this.profile.musicKey, @@ -187,28 +275,15 @@ export class CityStayScene extends Phaser.Scene { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.ready = false; this.navigationPending = false; - this.moveTarget = undefined; + this.clearNavigationTarget(true); this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; - releaseUnitBaseSheetTextures(this); - }); - - const textureKeys = this.characterTextureKeys(); - loadUnitBaseSheets(this, textureKeys, () => { - if (!this.scene.isActive()) { - return; - } - ensureUnitAnimations(this, textureKeys); - this.createActors(); - this.createDialoguePanel(); - this.loadingOverlay?.destroy(); - this.loadingOverlay = undefined; - this.ready = true; - this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.refreshProgressHud(); - this.refreshActorMarkers(); - this.refreshInteractionPrompt(); + releaseExplorationCharacterTextureKeys( + this, + this.characterTextureKeys() + ); + this.releasePresentationTextures(); }); } @@ -257,19 +332,49 @@ export class CityStayScene extends Phaser.Scene { cityName: this.cityStay.city.name, viewport: { width: this.scale.width, height: this.scale.height }, activeCityStayId: campaign.activeCityStayId ?? null, + background: { + textureKey: + this.backgroundImage?.texture.key ?? + this.currentBackground().textureKey, + ready: this.backgroundImage?.active === true, + fallback: this.backgroundFallback, + sourceWidth: + this.backgroundImage?.frame.realWidth ?? null, + sourceHeight: + this.backgroundImage?.frame.realHeight ?? null, + bounds: this.backgroundImage + ? this.boundsSnapshot(this.backgroundImage.getBounds()) + : null + }, player: playerPosition ? { ...playerPosition, direction: this.playerDirection, moving: this.playerMoving, + textureKey: this.player?.texture.key ?? null, + animationKey: + this.player?.anims.currentAnim?.key ?? null, bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null } : null, movement: { speed: playerSpeed, target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null, + targetId: this.moveTargetId ?? null, + lastTargetId: this.lastNavigationTargetId ?? null, + waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), bounds: this.boundsSnapshot(movementBounds), - collisionRadius: playerCollisionRadius + collisionRadius: playerCollisionRadius, + detourCount: this.navigationDetourCount, + autoInteraction: { + pending: this.autoInteractionPending, + targetId: this.autoInteractionPending + ? this.moveTargetId ?? null + : null, + triggeredCount: this.autoInteractionTriggeredCount, + lastTriggeredTargetId: + this.lastAutoInteractionTargetId ?? null + } }, controls: { movement: ['WASD', '방향키'], @@ -300,9 +405,15 @@ export class CityStayScene extends Phaser.Scene { kind: definition.kind, name: definition.name, role: definition.role, - textureKey: definition.textureKey, + textureKey: sprite.texture.key, + animationKey: sprite.anims.currentAnim?.key ?? null, x: sprite.x, y: sprite.y, + initialX: definition.x, + initialY: definition.y, + moved: + Math.abs(sprite.x - definition.x) > 0.1 || + Math.abs(sprite.y - definition.y) > 0.1, distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null, @@ -331,8 +442,17 @@ export class CityStayScene extends Phaser.Scene { lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, sourceTargetId: this.dialogueState.sourceTargetId ?? null, + portraitTextureKey: this.dialoguePortrait?.visible + ? this.dialoguePortrait.texture.key + : null, + portraitVisible: + this.dialoguePortrait?.visible ?? false, + portraitBounds: + this.dialoguePortrait?.visible + ? this.dialoguePortraitVisibleBounds() + : null, bounds: this.dialoguePanel?.visible - ? this.boundsSnapshot(this.dialoguePanel.getBounds()) + ? this.boundsSnapshot(dialoguePanelBounds) : null } : { @@ -341,6 +461,9 @@ export class CityStayScene extends Phaser.Scene { lineIndex: null, totalLines: 0, sourceTargetId: null, + portraitTextureKey: null, + portraitVisible: false, + portraitBounds: null, bounds: null }, shop: { @@ -397,6 +520,7 @@ export class CityStayScene extends Phaser.Scene { blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)), navigationPending: this.navigationPending, lastNotice: this.lastNotice, + requiredTextureKeys: this.characterTextureKeys(), requiredTexturesReady: this.characterTextureKeys().every((key) => this.textures.exists(key)) }; } @@ -430,6 +554,8 @@ export class CityStayScene extends Phaser.Scene { private resetRuntimeState() { this.campaign = getCampaignState(); + this.backgroundImage = undefined; + this.backgroundFallback = false; this.player = undefined; this.playerShadow = undefined; this.playerNameplate = undefined; @@ -437,6 +563,8 @@ export class CityStayScene extends Phaser.Scene { this.playerMoving = false; this.actors.clear(); this.blockers = []; + this.dialoguePortraitFrame = undefined; + this.dialoguePortrait = undefined; this.dialogueState = undefined; this.shopPanel = undefined; this.shopOfferViews = []; @@ -444,6 +572,14 @@ export class CityStayScene extends Phaser.Scene { this.choicePanel = undefined; this.choiceViews = []; this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetId = undefined; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + this.navigationDetourCount = 0; + this.autoInteractionPending = false; + this.autoInteractionTriggeredCount = 0; + this.lastAutoInteractionTargetId = undefined; this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; @@ -457,31 +593,136 @@ export class CityStayScene extends Phaser.Scene { this.campaign = setActiveCityStayId(this.cityStay.id); } - private characterTextureKeys() { + private currentBackground() { + return cityBackgrounds[this.cityStay.id]; + } + + private characterTextureKeys(): ExplorationCharacterTextureKey[] { return [ ...new Set([ - 'unit-liu-bei', - ...this.profile.actors.map((actor) => actor.textureKey) + playerTextureKey, + ...this.profile.actors.map((actor) => + this.actorExplorationTextureKey(actor) + ) ]) ]; } - private drawCity() { - const graphics = this.add.graphics().setDepth(-100); - this.cameras.main.setBackgroundColor(`#${this.profile.groundColor.toString(16).padStart(6, '0')}`); - graphics.fillStyle(this.profile.groundColor, 1); - graphics.fillRect(0, 0, sceneWidth, sceneHeight); - graphics.fillStyle(this.profile.grassColor, 1); - graphics.fillRect(0, 92, mapRight, 868); + private actorExplorationTextureKey( + actor: CityStayExplorationActor + ): ExplorationCharacterTextureKey { + const registered = explorationCharacterAssetInfo( + actor.textureKey + )?.key; + if (registered) { + return registered; + } + if (actor.kind === 'market') { + return 'exploration-zhuo-quartermaster'; + } + if (actor.kind === 'information') { + return this.cityStay.id === 'xinye' + ? 'exploration-shu-infantry' + : 'exploration-zhuo-recruiting-clerk'; + } + const companionKeys: Readonly< + Record + > = { + xuzhou: 'exploration-mi-zhu', + xinye: 'exploration-zhuge-liang', + chengdu: 'exploration-huang-quan' + }; + return companionKeys[ + this.cityStay.id + ] as ExplorationCharacterTextureKey; + } - this.drawGroundTexture(graphics); - this.drawRoads(graphics); - this.profile.buildings.forEach((building) => { - this.drawBuilding(graphics, building); - this.addBlocker(building.x, building.y, building.width, building.height); - }); - this.drawCityDetails(); - this.drawCampGate(); + private portraitContext(): StoryPortraitContext { + const backgroundByCity: Readonly> = { + xuzhou: 'story-xuzhou', + xinye: 'story-wandering', + chengdu: 'story-yizhou' + }; + return { + id: `city-stay-${this.cityStay.id}`, + background: backgroundByCity[this.cityStay.id] + }; + } + + private actorPortraitKey(actor: CityStayExplorationActor) { + if (actor.kind === 'market') { + return 'zhuoQuartermaster'; + } + if (actor.kind === 'information') { + return 'zhuoRecruitingClerk'; + } + const companionPortraitKeys: Readonly< + Record + > = { + xuzhou: 'mi-zhu', + xinye: 'zhugeLiang', + chengdu: 'huang-quan' + }; + return companionPortraitKeys[this.cityStay.id]; + } + + private currentPortraitEntries(): PortraitAssetEntry[] { + return [ + 'liuBei', + ...this.profile.actors.map((actor) => + this.actorPortraitKey(actor) + ) + ] + .map((portraitKey) => + this.portraitEntry(portraitKey) + ) + .filter( + (entry): entry is PortraitAssetEntry => + entry !== undefined + ) + .filter( + (entry, index, entries) => + entries.findIndex( + (candidate) => + candidate.textureKey === entry.textureKey + ) === index + ); + } + + private drawCity() { + const background = this.currentBackground(); + this.cameras.main.setBackgroundColor( + `#${this.profile.groundColor.toString(16).padStart(6, '0')}` + ); + if (this.textures.exists(background.textureKey)) { + this.backgroundImage = this.add + .image(0, 0, background.textureKey) + .setOrigin(0) + .setDisplaySize(sceneWidth, sceneHeight) + .setDepth(-120); + this.registerCityCollisionGeometry(); + this.drawCityWayfinding(); + } else { + this.backgroundFallback = true; + const graphics = this.add.graphics().setDepth(-100); + graphics.fillStyle(this.profile.groundColor, 1); + graphics.fillRect(0, 0, sceneWidth, sceneHeight); + graphics.fillStyle(this.profile.grassColor, 1); + graphics.fillRect(0, 92, mapRight, 868); + this.drawGroundTexture(graphics); + this.drawRoads(graphics); + this.profile.buildings.forEach((building) => { + this.drawBuilding(graphics, building); + this.addBlocker( + building.x, + building.y, + building.width, + building.height + ); + }); + this.drawCityDetails(); + this.drawCampGate(); + } this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.97) .setOrigin(0) @@ -503,6 +744,72 @@ export class CityStayScene extends Phaser.Scene { .setDepth(1401); } + private registerCityCollisionGeometry() { + this.profile.buildings.forEach((building) => { + this.addBlocker( + building.x, + building.y, + building.width, + building.height + ); + }); + this.addBlocker( + movementBounds.left, + 846, + 620, + 112 + ); + this.addBlocker( + 900, + 846, + movementBounds.right - 900, + 112 + ); + } + + private drawCityWayfinding() { + const labels = [ + { + x: 310, + y: 418, + text: this.profile.buildings[0].label + }, + { + x: 1180, + y: 408, + text: this.profile.buildings[1].label + }, + { + x: 1190, + y: 650, + text: this.profile.buildings[2].label + }, + { + x: 780, + y: 510, + text: this.profile.landmarkLabel + }, + { + x: this.profile.exit.x, + y: 884, + text: '남쪽 · 군영 복귀' + } + ]; + labels.forEach(({ x, y, text }) => { + this.add + .text(x, y, text, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f4e3b5', + fontStyle: 'bold', + backgroundColor: '#17130fd4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5) + .setDepth(45); + }); + } + private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) { for (let index = 0; index < 150; index += 1) { const x = 25 + ((index * 83) % 1435); @@ -798,22 +1105,16 @@ export class CityStayScene extends Phaser.Scene { return background; } - private createLoadingOverlay() { - const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.74).setOrigin(0); - const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 520, 126, 0x151b24, 0.98) - .setStrokeStyle(2, this.profile.accentColor, 0.82); - const text = this.add.text(sceneWidth / 2, sceneHeight / 2, `${this.cityStay.city.name}의 사람들을 불러오는 중...`, { - fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '24px', - color: '#f3dfaa' - }).setOrigin(0.5); - this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000); - } - private createActors() { const start = this.profile.playerStart; this.playerShadow = this.add.ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45).setDepth(800); - this.player = this.createCharacterSprite('unit-liu-bei', start.x, start.y, characterDisplaySize + 8, start.direction); + this.player = this.createCharacterSprite( + playerTextureKey, + start.x, + start.y, + characterDisplaySize + 8, + start.direction + ); this.player.setDepth(100 + start.y); this.playerNameplate = this.add.text(start.x, start.y + 66, '유비', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -824,10 +1125,12 @@ export class CityStayScene extends Phaser.Scene { }).setOrigin(0.5).setDepth(1200); this.profile.actors.forEach((definition) => { + const textureKey = + this.actorExplorationTextureKey(definition); const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4) .setDepth(100 + definition.y); const sprite = this.createCharacterSprite( - definition.textureKey, + textureKey, definition.x, definition.y, characterDisplaySize, @@ -857,7 +1160,15 @@ export class CityStayScene extends Phaser.Scene { repeat: -1, ease: 'Sine.easeInOut' }); - this.actors.set(definition.id, { definition, sprite, shadow, nameplate, marker }); + this.actors.set(definition.id, { + definition, + textureKey, + sprite, + shadow, + nameplate, + marker, + initialPosition: { x: definition.x, y: definition.y } + }); }); const exit = this.profile.exit; @@ -879,25 +1190,33 @@ export class CityStayScene extends Phaser.Scene { } private createCharacterSprite( - textureKey: string, + textureKey: ExplorationCharacterTextureKey, x: number, y: number, size: number, - direction: UnitDirection + direction: ExplorationCharacterDirection ) { const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT'; const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size); if (this.textures.exists(textureKey)) { - sprite.play(`${textureKey}-idle-${direction}`); + sprite.play( + explorationCharacterAnimationKey( + textureKey, + 'idle', + direction + ) + ); } return sprite; } private createDialoguePanel() { - const x = 72; - const y = 710; - const width = 1776; - const height = 304; + const { + x, + y, + width, + height + } = dialoguePanelBounds; const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.45) .setOrigin(0) .setInteractive(); @@ -907,22 +1226,28 @@ export class CityStayScene extends Phaser.Scene { const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.84) .setOrigin(0) .setStrokeStyle(1, 0x546174, 0.72); - const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92) + this.dialoguePortraitFrame = this.add + .rectangle(x + 130, y + 152, 232, 236, 0x0e131a, 0.92) .setStrokeStyle(2, 0x9e8350, 0.9); - this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0) - .setDisplaySize(186, 186); - this.dialogueNameText = this.add.text(x + 222, y + 42, '', { + this.dialoguePortrait = this.add + .image(x + 130, y + 152, '__DEFAULT') + .setDisplaySize( + dialoguePortraitDisplaySize, + dialoguePortraitDisplaySize + ) + .setVisible(false); + this.dialogueNameText = this.add.text(x + 270, y + 42, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '29px', color: '#f1d691', fontStyle: 'bold' }); - this.dialogueBodyText = this.add.text(x + 222, y + 101, '', { + this.dialogueBodyText = this.add.text(x + 270, y + 101, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '27px', color: '#f0ede5', lineSpacing: 11, - wordWrap: { width: 1440, useAdvancedWrap: true } + wordWrap: { width: 1360, useAdvancedWrap: true } }); this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -934,8 +1259,8 @@ export class CityStayScene extends Phaser.Scene { shade, background, inner, - avatarFrame, - this.dialogueAvatar, + this.dialoguePortraitFrame, + this.dialoguePortrait, this.dialogueNameText, this.dialogueBodyText, this.dialogueProgressText @@ -974,7 +1299,10 @@ export class CityStayScene extends Phaser.Scene { pointer.worldY >= movementBounds.top && pointer.worldY <= movementBounds.bottom ) { - this.moveTarget = new Phaser.Math.Vector2(pointer.worldX, pointer.worldY); + this.beginPointerMovement( + pointer.worldX, + pointer.worldY + ); } }); } @@ -1004,12 +1332,33 @@ export class CityStayScene extends Phaser.Scene { } const keyboardDirection = this.keyboardMovementDirection(); let movement = keyboardDirection; + let pointerDistanceRemaining = Number.POSITIVE_INFINITY; if (movement.lengthSq() > 0) { - this.moveTarget = undefined; + this.clearNavigationTarget(true); } else if (this.moveTarget) { - const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y)); - if (targetDelta.length() <= 7) { - this.moveTarget = undefined; + const activeTarget = + this.moveWaypoints[0] ?? this.moveTarget; + const targetDelta = activeTarget + .clone() + .subtract( + new Phaser.Math.Vector2( + this.player.x, + this.player.y + ) + ); + pointerDistanceRemaining = targetDelta.length(); + if (pointerDistanceRemaining <= navigationArrivalRadius) { + if (this.moveWaypoints.length > 0) { + this.moveWaypoints.shift(); + } else { + const arrivedTargetId = this.moveTargetId; + this.lastNavigationTargetId = arrivedTargetId; + this.clearNavigationTarget(false); + this.setPlayerMoving(false); + if (arrivedTargetId) { + this.triggerAutoInteraction(arrivedTargetId); + } + } movement = new Phaser.Math.Vector2(); } else { movement = targetDelta.normalize(); @@ -1023,7 +1372,10 @@ export class CityStayScene extends Phaser.Scene { movement.normalize(); this.playerDirection = this.directionForVector(movement); - const distance = playerSpeed * Math.min(delta, 50) / 1000; + const distance = Math.min( + playerSpeed * Math.min(delta, 50) / 1000, + pointerDistanceRemaining + ); const startX = this.player.x; const startY = this.player.y; const nextX = startX + movement.x * distance; @@ -1039,7 +1391,9 @@ export class CityStayScene extends Phaser.Scene { moved = true; } if (!moved && this.moveTarget) { - this.moveTarget = undefined; + if (!this.replanBlockedPointerMovement()) { + this.clearNavigationTarget(true); + } } this.syncPlayerView(); @@ -1056,6 +1410,399 @@ export class CityStayScene extends Phaser.Scene { } } + private beginPointerMovement(x: number, y: number) { + const snappedTarget = this.pointerMovementTargetAt(x, y); + if (!snappedTarget) { + this.beginNavigationTo(x, y); + return; + } + const plannedApproaches = this.approachPositions( + snappedTarget.x, + snappedTarget.y + ) + .map((destination) => ({ + destination, + route: this.planNavigationRoute(destination) + })) + .filter( + ( + entry + ): entry is { + destination: Phaser.Math.Vector2; + route: Phaser.Math.Vector2[]; + } => Boolean(entry.route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left.route) - + this.navigationRouteLength(right.route) + ); + const best = plannedApproaches[0]; + if (best) { + this.applyNavigationRoute(best.route, snappedTarget.id); + return; + } + const fallback = this.safeApproachPosition( + snappedTarget.x, + snappedTarget.y + ); + this.beginNavigationTo( + fallback.x, + fallback.y, + snappedTarget.id + ); + } + + private beginNavigationTo( + x: number, + y: number, + targetId?: CityInteractionTarget['id'] + ) { + const destination = this.nearestWalkablePoint(x, y); + if (!destination) { + this.clearNavigationTarget(true); + return; + } + const route = this.planNavigationRoute(destination); + if (!route) { + this.clearNavigationTarget(true); + return; + } + this.applyNavigationRoute(route, targetId); + } + + private applyNavigationRoute( + route: Phaser.Math.Vector2[], + targetId?: CityInteractionTarget['id'] + ) { + const destination = route[route.length - 1]; + if (!destination) { + this.clearNavigationTarget(true); + return; + } + this.moveTarget = destination.clone(); + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + this.moveTargetId = targetId; + this.lastNavigationTargetId = undefined; + this.navigationReplanAttempts = 0; + this.autoInteractionPending = Boolean(targetId); + if (route.length > 1) { + this.navigationDetourCount += 1; + } + } + + private pointerMovementTargetAt(x: number, y: number) { + return this.allInteractionTargets() + .map((target) => ({ + target, + distance: Phaser.Math.Distance.Between( + x, + y, + target.x, + target.y + ) + })) + .filter( + ({ distance }) => + distance <= directPointerSnapRadius + ) + .sort( + (left, right) => left.distance - right.distance + )[0]?.target; + } + + private approachPositions(targetX: number, targetY: number) { + return [ + new Phaser.Math.Vector2(targetX, targetY + 88), + new Phaser.Math.Vector2(targetX - 88, targetY), + new Phaser.Math.Vector2(targetX + 88, targetY), + new Phaser.Math.Vector2(targetX, targetY - 88) + ].filter(({ x, y }) => this.canPlayerOccupy(x, y)); + } + + private nearestWalkablePoint(x: number, y: number) { + const margin = playerCollisionRadius + 4; + const clamped = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(clamped.x, clamped.y)) { + return clamped; + } + for (const radius of [48, 80, 112, 144]) { + for (let index = 0; index < 8; index += 1) { + const angle = (index * Math.PI) / 4; + const candidate = new Phaser.Math.Vector2( + Phaser.Math.Clamp( + clamped.x + Math.cos(angle) * radius, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + clamped.y + Math.sin(angle) * radius, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (this.canPlayerOccupy(candidate.x, candidate.y)) { + return candidate; + } + } + } + return undefined; + } + + private planNavigationRoute( + destination: Phaser.Math.Vector2 + ) { + if ( + !this.player || + !this.canPlayerOccupy(destination.x, destination.y) + ) { + return undefined; + } + const start = new Phaser.Math.Vector2( + this.player.x, + this.player.y + ); + if (this.segmentIsNavigable(start, destination)) { + return [destination.clone()]; + } + + const candidates = this.navigationDetourCandidates( + start, + destination + ); + let bestRoute: Phaser.Math.Vector2[] | undefined; + let bestLength = Number.POSITIVE_INFINITY; + const startVisible = candidates.filter((candidate) => + this.segmentIsNavigable(start, candidate) + ); + const destinationVisible = candidates.filter((candidate) => + this.segmentIsNavigable(candidate, destination) + ); + + startVisible.forEach((waypoint) => { + if (!this.segmentIsNavigable(waypoint, destination)) { + return; + } + const route = [waypoint, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + startVisible.forEach((first) => { + destinationVisible.forEach((second) => { + if ( + first.equals(second) || + !this.segmentIsNavigable(first, second) + ) { + return; + } + const route = [first, second, destination]; + const length = this.navigationRouteLength(route); + if (length < bestLength) { + bestLength = length; + bestRoute = route; + } + }); + }); + return bestRoute?.map((waypoint) => waypoint.clone()); + } + + private navigationDetourCandidates( + start: Phaser.Math.Vector2, + destination: Phaser.Math.Vector2 + ) { + const margin = playerCollisionRadius + 4; + const candidates = [ + new Phaser.Math.Vector2(start.x, destination.y), + new Phaser.Math.Vector2(destination.x, start.y) + ]; + this.blockers.forEach((blocker) => { + const left = blocker.left - navigationDetourClearance; + const right = blocker.right + navigationDetourClearance; + const top = blocker.top - navigationDetourClearance; + const bottom = blocker.bottom + navigationDetourClearance; + candidates.push( + new Phaser.Math.Vector2(left, top), + new Phaser.Math.Vector2(right, top), + new Phaser.Math.Vector2(left, bottom), + new Phaser.Math.Vector2(right, bottom) + ); + }); + this.actors.forEach(({ sprite }) => { + candidates.push( + new Phaser.Math.Vector2(sprite.x - 72, sprite.y), + new Phaser.Math.Vector2(sprite.x + 72, sprite.y), + new Phaser.Math.Vector2(sprite.x, sprite.y - 72), + new Phaser.Math.Vector2(sprite.x, sprite.y + 72) + ); + }); + + const unique = new Map(); + candidates.forEach((candidate) => { + candidate.set( + Phaser.Math.Clamp( + candidate.x, + movementBounds.left + margin, + movementBounds.right - margin + ), + Phaser.Math.Clamp( + candidate.y, + movementBounds.top + margin, + movementBounds.bottom - margin + ) + ); + if (!this.canPlayerOccupy(candidate.x, candidate.y)) { + return; + } + unique.set( + `${Math.round(candidate.x)}:${Math.round(candidate.y)}`, + candidate + ); + }); + return Array.from(unique.values()); + } + + private segmentIsNavigable( + from: Phaser.Math.Vector2, + to: Phaser.Math.Vector2 + ) { + const distance = Phaser.Math.Distance.Between( + from.x, + from.y, + to.x, + to.y + ); + const steps = Math.max( + 1, + Math.ceil(distance / navigationProbeStep) + ); + for (let index = 1; index <= steps; index += 1) { + const progress = index / steps; + const x = Phaser.Math.Linear(from.x, to.x, progress); + const y = Phaser.Math.Linear(from.y, to.y, progress); + if (!this.canPlayerOccupy(x, y)) { + return false; + } + } + return true; + } + + private navigationRouteLength( + route: readonly Phaser.Math.Vector2[] + ) { + if (!this.player) { + return Number.POSITIVE_INFINITY; + } + let previous = new Phaser.Math.Vector2( + this.player.x, + this.player.y + ); + return route.reduce((total, waypoint) => { + const length = Phaser.Math.Distance.Between( + previous.x, + previous.y, + waypoint.x, + waypoint.y + ); + previous = waypoint; + return total + length; + }, 0); + } + + private replanBlockedPointerMovement() { + if ( + !this.moveTarget || + this.navigationReplanAttempts >= 2 + ) { + return false; + } + this.navigationReplanAttempts += 1; + const route = this.planNavigationRoute(this.moveTarget); + if (!route) { + return false; + } + this.moveWaypoints = route + .slice(0, -1) + .map((waypoint) => waypoint.clone()); + return true; + } + + private clearNavigationTarget(clearLast = false) { + this.moveTarget = undefined; + this.moveWaypoints = []; + this.moveTargetId = undefined; + this.navigationReplanAttempts = 0; + this.autoInteractionPending = false; + if (clearLast) { + this.lastNavigationTargetId = undefined; + } + } + + private triggerAutoInteraction( + targetId: CityInteractionTarget['id'] + ) { + if ( + this.navigationPending || + this.modalOpen() || + !this.ready + ) { + return; + } + const target = this.interactionTargetByIdOrKind(targetId); + if (!target) { + return; + } + if ( + this.distanceTo(target.x, target.y) > + interactionRadius + 8 + ) { + const recoveryRoute = this.approachPositions( + target.x, + target.y + ) + .map((destination) => + this.planNavigationRoute(destination) + ) + .filter( + ( + route + ): route is Phaser.Math.Vector2[] => + Boolean(route) + ) + .sort( + (left, right) => + this.navigationRouteLength(left) - + this.navigationRouteLength(right) + )[0]; + if (recoveryRoute) { + this.applyNavigationRoute(recoveryRoute, target.id); + return; + } + this.showPromptMessage( + `${target.name}에게 다가갈 길을 찾지 못했습니다.` + ); + return; + } + this.autoInteractionTriggeredCount += 1; + this.lastAutoInteractionTargetId = target.id; + this.interactWithTarget(target); + } + private consumeInteractionRequest() { return this.explorationInput?.consumeInteractionRequest() ?? false; } @@ -1067,7 +1814,9 @@ export class CityStayScene extends Phaser.Scene { ); } - private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { + private directionForVector( + vector: Phaser.Math.Vector2 + ): ExplorationCharacterDirection { if (Math.abs(vector.x) > Math.abs(vector.y)) { return vector.x > 0 ? 'east' : 'west'; } @@ -1101,15 +1850,22 @@ export class CityStayScene extends Phaser.Scene { if (!this.player) { return; } - const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`; - if (this.textures.exists('unit-liu-bei') && this.player.anims.currentAnim?.key !== animationKey) { + const animationKey = explorationCharacterAnimationKey( + playerTextureKey, + moving ? 'walk' : 'idle', + this.playerDirection + ); + if ( + this.textures.exists(playerTextureKey) && + this.player.anims.currentAnim?.key !== animationKey + ) { this.player.play(animationKey); } this.playerMoving = moving; } private stopPlayerMovement() { - this.moveTarget = undefined; + this.clearNavigationTarget(); this.setPlayerMoving(false); } @@ -1183,12 +1939,12 @@ export class CityStayScene extends Phaser.Scene { this.startDialogue([ { speaker: view.definition.name, - textureKey: view.definition.textureKey, + portraitKey: this.actorPortraitKey(view.definition), text: `${information.title}은 이미 장부에 기록했습니다.` }, ...information.briefingLines.map((text) => ({ speaker: '확보한 정보', - textureKey: view.definition.textureKey, + portraitKey: this.actorPortraitKey(view.definition), text })) ], undefined, view.definition.id); @@ -1198,17 +1954,17 @@ export class CityStayScene extends Phaser.Scene { this.startDialogue([ { speaker: view.definition.name, - textureKey: view.definition.textureKey, + portraitKey: this.actorPortraitKey(view.definition), text: information.description }, { speaker: '유비', - textureKey: 'unit-liu-bei', + portraitKey: 'liuBei', text: '현지의 증언과 장부를 함께 대조해 다음 싸움에서 놓칠 길이 없도록 합시다.' }, ...information.briefingLines.map((text) => ({ speaker: view.definition.name, - textureKey: view.definition.textureKey, + portraitKey: this.actorPortraitKey(view.definition), text })) ], () => this.completeInformation(), view.definition.id); @@ -1229,7 +1985,7 @@ export class CityStayScene extends Phaser.Scene { } this.campaign = result.campaign; this.lastNotice = `정보 확보 · ${result.information.title}`; - soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 }); + soundDirector.playOperationAlert(); this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`); this.refreshProgressHud(); this.refreshActorMarkers(); @@ -1244,7 +2000,7 @@ export class CityStayScene extends Phaser.Scene { this.startDialogue([ { speaker: view.definition.name, - textureKey: view.definition.textureKey, + portraitKey: this.actorPortraitKey(view.definition), text: choice?.response ?? '함께 나눈 결정은 이미 다음 출전을 위한 약속으로 남아 있습니다.' }, { @@ -1266,7 +2022,10 @@ export class CityStayScene extends Phaser.Scene { return { speaker, text, - textureKey: speaker === '유비' ? 'unit-liu-bei' : companion.textureKey + portraitKey: + speaker === '유비' + ? 'liuBei' + : this.actorPortraitKey(companion) }; } @@ -1386,7 +2145,9 @@ export class CityStayScene extends Phaser.Scene { this.startDialogue([ { speaker: this.companionActor().name, - textureKey: this.companionActor().textureKey, + portraitKey: this.actorPortraitKey( + this.companionActor() + ), text: choice.response }, { @@ -1411,6 +2172,11 @@ export class CityStayScene extends Phaser.Scene { this.completionToast?.destroy(); this.completionToast = undefined; this.shopNotice = ''; + soundDirector.playEffect('cart-roll', { + volume: 0.14, + rate: 0.96, + stopAfterMs: 720 + }); this.renderShopPanel(); this.refreshInteractionPrompt(); } @@ -1714,7 +2480,7 @@ export class CityStayScene extends Phaser.Scene { this.startDialogue([ { speaker: '유비', - textureKey: 'unit-liu-bei', + portraitKey: 'liuBei', text: `${pending.join('과 ')}이 아직 남아 있다. 체류 활동은 선택이니 필요하면 다음에 다시 들르자.` }, { @@ -1823,20 +2589,77 @@ export class CityStayScene extends Phaser.Scene { return; } const line = dialogue.lines[dialogue.lineIndex]; - this.dialogueNameText?.setText(line.speaker); - this.dialogueBodyText?.setText(line.text); this.dialogueProgressText?.setText( `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` ); - if (this.dialogueAvatar) { - const textureKey = line.textureKey; - if (textureKey && this.textures.exists(textureKey)) { - this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true); - this.dialogueAvatar.play(`${textureKey}-idle-south`); - } else { - this.dialogueAvatar.setVisible(false); - } + const portraitEntry = this.dialoguePortraitEntry(line); + const portraitVisible = Boolean( + this.dialoguePortrait && + portraitEntry && + this.textures.exists(portraitEntry.textureKey) + ); + const textX = portraitVisible ? 342 : 118; + const bodyWidth = portraitVisible ? 1360 : 1640; + this.dialogueNameText + ?.setX(textX) + .setText(line.speaker); + this.dialogueBodyText + ?.setX(textX) + .setWordWrapWidth(bodyWidth, true) + .setText(line.text); + if ( + portraitVisible && + portraitEntry && + this.dialoguePortrait + ) { + this.dialoguePortraitFrame?.setVisible(true); + this.dialoguePortrait + .setTexture(portraitEntry.textureKey) + .setVisible(true); + this.fitDialogueFacePortrait(this.dialoguePortrait); + return; } + this.dialoguePortraitFrame?.setVisible(false); + this.dialoguePortrait?.setVisible(false); + } + + private dialoguePortraitEntry(line: CityDialogueLine) { + return line.portraitKey + ? this.portraitEntry(line.portraitKey) + : undefined; + } + + private portraitEntry(portraitKey: string) { + const context = this.portraitContext(); + return portraitAssetEntryForStoryContext( + portraitKey, + context, + `${context.id}-${portraitKey}` + ); + } + + private fitDialogueFacePortrait( + portrait: Phaser.GameObjects.Image + ) { + const sourceWidth = portrait.frame.realWidth; + const sourceHeight = portrait.frame.realHeight; + const cropSize = Math.round( + Math.min(sourceWidth, sourceHeight) * 0.6 + ); + const cropX = Math.round((sourceWidth - cropSize) / 2); + const cropY = Math.round( + Math.min( + Math.max(0, sourceHeight * 0.035), + sourceHeight - cropSize + ) + ); + portrait + .setCrop(cropX, cropY, cropSize, cropSize) + .setOrigin( + (cropX + cropSize / 2) / sourceWidth, + (cropY + cropSize / 2) / sourceHeight + ) + .setScale(dialoguePortraitDisplaySize / cropSize); } private advanceDialogue() { @@ -2011,8 +2834,14 @@ export class CityStayScene extends Phaser.Scene { this.playerDirection = this.directionForVector(toNpc); this.setPlayerMoving(false); const npcDirection = this.directionForVector(toNpc.scale(-1)); - if (this.textures.exists(view.definition.textureKey)) { - view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`); + if (this.textures.exists(view.textureKey)) { + view.sprite.play( + explorationCharacterAnimationKey( + view.textureKey, + 'idle', + npcDirection + ) + ); } } @@ -2047,6 +2876,29 @@ export class CityStayScene extends Phaser.Scene { return Boolean(this.dialogueState || this.shopPanel || this.choicePanel); } + private releasePresentationTextures() { + this.ownedPresentationTextureKeys.forEach((textureKey) => { + releaseTextureSource(this, textureKey); + }); + this.ownedPresentationTextureKeys.clear(); + } + + private dialoguePortraitVisibleBounds() { + if (!this.dialoguePortrait) { + return null; + } + return { + x: + this.dialoguePortrait.x - + dialoguePortraitDisplaySize / 2, + y: + this.dialoguePortrait.y - + dialoguePortraitDisplaySize / 2, + width: dialoguePortraitDisplaySize, + height: dialoguePortraitDisplaySize + }; + } + private boundsSnapshot(bounds: Phaser.Geom.Rectangle) { return { x: bounds.x,