feat: upgrade city stay exploration
This commit is contained in:
142
docs/city-stay-immersion-upgrade-report.md
Normal file
142
docs/city-stay-immersion-upgrade-report.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`<br>`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`<br>`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`<br>`twentySeventhBattleIntroPages/twenty-seventh-gate-warning`<br>`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`<br>`twentyFourthBattleIntroPages/twenty-fourth-yi-ji-rumor`<br>`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`<br>`thirtySixthBattleIntroPages/thirty-sixth-dingjun-mountain`<br>`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`<br>`twentyThirdBattleVictoryPages/twenty-third-victory-southern-road`<br>`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`<br>`fortySeventhBattleVictoryPages/forty-seventh-next-nanman`<br>`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`<br>`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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
BIN
src/assets/images/exploration/characters/exploration-mi-zhu.webp
Normal file
BIN
src/assets/images/exploration/characters/exploration-mi-zhu.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
BIN
src/assets/images/exploration/city-chengdu-stay.webp
Normal file
BIN
src/assets/images/exploration/city-chengdu-stay.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 470 KiB |
BIN
src/assets/images/exploration/city-xinye-stay.webp
Normal file
BIN
src/assets/images/exploration/city-xinye-stay.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
BIN
src/assets/images/exploration/city-xuzhou-stay.webp
Normal file
BIN
src/assets/images/exploration/city-xuzhou-stay.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 424 KiB |
@@ -72,7 +72,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'information',
|
||||
name: '서주 서리',
|
||||
role: '관청 장부 담당',
|
||||
textureKey: 'unit-shu-strategist',
|
||||
textureKey: 'exploration-zhuo-recruiting-clerk',
|
||||
x: 365,
|
||||
y: 485,
|
||||
direction: 'east'
|
||||
@@ -82,7 +82,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'market',
|
||||
name: '서주 대장장이',
|
||||
role: '일반 장비 거래',
|
||||
textureKey: 'unit-shu-infantry-guard',
|
||||
textureKey: 'exploration-zhuo-quartermaster',
|
||||
x: 1110,
|
||||
y: 480,
|
||||
direction: 'west'
|
||||
@@ -92,7 +92,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'dialogue',
|
||||
name: '미축',
|
||||
role: '서주의 창고와 민심',
|
||||
textureKey: 'unit-shu-officer-council',
|
||||
textureKey: 'exploration-mi-zhu',
|
||||
x: 1015,
|
||||
y: 770,
|
||||
direction: 'west'
|
||||
@@ -102,30 +102,30 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
{
|
||||
id: 'xuzhou-office',
|
||||
label: '서주 관청',
|
||||
x: 105,
|
||||
y: 155,
|
||||
width: 410,
|
||||
height: 235,
|
||||
x: 42,
|
||||
y: 108,
|
||||
width: 550,
|
||||
height: 300,
|
||||
wallColor: 0xc1a06d,
|
||||
roofColor: 0x6c3027
|
||||
},
|
||||
{
|
||||
id: 'xuzhou-market',
|
||||
label: '장인 거리',
|
||||
x: 990,
|
||||
y: 150,
|
||||
width: 390,
|
||||
height: 225,
|
||||
x: 850,
|
||||
y: 108,
|
||||
width: 602,
|
||||
height: 280,
|
||||
wallColor: 0xbda471,
|
||||
roofColor: 0x334a5d
|
||||
},
|
||||
{
|
||||
id: 'xuzhou-guest-hall',
|
||||
label: '미축 객관',
|
||||
x: 1090,
|
||||
y: 670,
|
||||
width: 330,
|
||||
height: 215,
|
||||
x: 1160,
|
||||
y: 420,
|
||||
width: 290,
|
||||
height: 380,
|
||||
wallColor: 0xab8b61,
|
||||
roofColor: 0x4b3930
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'information',
|
||||
name: '신야 길잡이',
|
||||
role: '박망파 숲길 정찰',
|
||||
textureKey: 'unit-shu-cavalry-scout',
|
||||
textureKey: 'exploration-shu-infantry',
|
||||
x: 365,
|
||||
y: 485,
|
||||
direction: 'east'
|
||||
@@ -162,7 +162,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'market',
|
||||
name: '신야 장인',
|
||||
role: '출진 장비 거래',
|
||||
textureKey: 'unit-shu-infantry-guard',
|
||||
textureKey: 'exploration-zhuo-quartermaster',
|
||||
x: 1110,
|
||||
y: 480,
|
||||
direction: 'west'
|
||||
@@ -172,7 +172,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'dialogue',
|
||||
name: '제갈량',
|
||||
role: '와룡의 첫 군령',
|
||||
textureKey: 'unit-zhuge-liang',
|
||||
textureKey: 'exploration-zhuge-liang',
|
||||
x: 1015,
|
||||
y: 770,
|
||||
direction: 'west'
|
||||
@@ -182,30 +182,30 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
{
|
||||
id: 'xinye-guesthouse',
|
||||
label: '신야 객사',
|
||||
x: 105,
|
||||
y: 155,
|
||||
width: 410,
|
||||
height: 235,
|
||||
x: 42,
|
||||
y: 108,
|
||||
width: 550,
|
||||
height: 300,
|
||||
wallColor: 0xb4a274,
|
||||
roofColor: 0x3d593d
|
||||
},
|
||||
{
|
||||
id: 'xinye-market',
|
||||
label: '북문 장터',
|
||||
x: 990,
|
||||
y: 150,
|
||||
width: 390,
|
||||
height: 225,
|
||||
x: 850,
|
||||
y: 108,
|
||||
width: 602,
|
||||
height: 280,
|
||||
wallColor: 0xb9a06f,
|
||||
roofColor: 0x635134
|
||||
},
|
||||
{
|
||||
id: 'xinye-command-hall',
|
||||
label: '군사 관사',
|
||||
x: 1090,
|
||||
y: 670,
|
||||
width: 330,
|
||||
height: 215,
|
||||
x: 1160,
|
||||
y: 420,
|
||||
width: 290,
|
||||
height: 380,
|
||||
wallColor: 0xa99670,
|
||||
roofColor: 0x2f4d50
|
||||
}
|
||||
@@ -232,7 +232,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'information',
|
||||
name: '익주 서리',
|
||||
role: '북문 장부 담당',
|
||||
textureKey: 'unit-shu-strategist-field',
|
||||
textureKey: 'exploration-zhuo-recruiting-clerk',
|
||||
x: 365,
|
||||
y: 485,
|
||||
direction: 'east'
|
||||
@@ -242,7 +242,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'market',
|
||||
name: '성도 대장장이',
|
||||
role: '서량 대비 장비 거래',
|
||||
textureKey: 'unit-shu-officer-vanguard',
|
||||
textureKey: 'exploration-zhuo-quartermaster',
|
||||
x: 1110,
|
||||
y: 480,
|
||||
direction: 'west'
|
||||
@@ -252,7 +252,7 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
kind: 'dialogue',
|
||||
name: '황권',
|
||||
role: '익주의 질서와 북문',
|
||||
textureKey: 'unit-huang-quan',
|
||||
textureKey: 'exploration-huang-quan',
|
||||
x: 1015,
|
||||
y: 770,
|
||||
direction: 'west'
|
||||
@@ -262,30 +262,30 @@ export const cityStayExplorationProfiles: Readonly<Record<CityStayId, CityStayEx
|
||||
{
|
||||
id: 'chengdu-office',
|
||||
label: '성도 임시 관청',
|
||||
x: 105,
|
||||
y: 155,
|
||||
width: 410,
|
||||
height: 235,
|
||||
x: 42,
|
||||
y: 108,
|
||||
width: 550,
|
||||
height: 300,
|
||||
wallColor: 0xc6ad77,
|
||||
roofColor: 0x704132
|
||||
},
|
||||
{
|
||||
id: 'chengdu-market',
|
||||
label: '성도 시장',
|
||||
x: 990,
|
||||
y: 150,
|
||||
width: 390,
|
||||
height: 225,
|
||||
x: 850,
|
||||
y: 108,
|
||||
width: 602,
|
||||
height: 280,
|
||||
wallColor: 0xc1a56f,
|
||||
roofColor: 0x4c5365
|
||||
},
|
||||
{
|
||||
id: 'chengdu-north-hall',
|
||||
label: '황권 관사',
|
||||
x: 1090,
|
||||
y: 670,
|
||||
width: 330,
|
||||
height: 215,
|
||||
x: 1160,
|
||||
y: 420,
|
||||
width: 290,
|
||||
height: 380,
|
||||
wallColor: 0xb29868,
|
||||
roofColor: 0x40382e
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export const explorationCharacterTextureKeyByUnitTextureKey = {
|
||||
|
||||
export const explorationCharacterNamedTextureKeyFallbacks = {
|
||||
'exploration-jian-yong': 'unit-shu-officer',
|
||||
'exploration-mi-zhu': 'unit-shu-officer',
|
||||
'exploration-zhuge-liang': 'unit-shu-officer',
|
||||
'exploration-huang-quan': 'unit-shu-officer',
|
||||
'exploration-zhuo-recruiting-clerk': 'unit-shu-officer',
|
||||
'exploration-zhuo-villager': 'unit-shu-infantry',
|
||||
'exploration-zhuo-quartermaster': 'unit-shu-officer',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user