feat: upgrade city stay exploration

This commit is contained in:
2026-07-28 00:12:10 +09:00
parent 9d67e0ae1d
commit 1a81ce5689
18 changed files with 1733 additions and 218 deletions

View 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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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

View File

@@ -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) {

View File

@@ -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`);

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

View File

@@ -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
}

View File

@@ -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