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,