diff --git a/docs/audio-sources.md b/docs/audio-sources.md index fd0258c..53363bc 100644 --- a/docs/audio-sources.md +++ b/docs/audio-sources.md @@ -1,6 +1,7 @@ # Audio Sources -Downloaded from Pixabay on 2026-06-22, 2026-07-21, and 2026-07-22 and used under the Pixabay Content License. +Downloaded from Pixabay on 2026-06-22, 2026-07-21, 2026-07-22, and +2026-07-27 and used under the Pixabay Content License. License reference: https://pixabay.com/service/license-summary/ @@ -29,6 +30,20 @@ The following files are generated deterministically by `scripts/generate-camp-au | `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps | | `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps | +## Daytime Exploration Ambience + +The following source was downloaded from its official Pixabay detail page on +2026-07-27 and is used under the Pixabay Content License linked above. + +| Project file | Role | Pixabay title | Creator | Source | +| --- | --- | --- | --- | --- | +| `src/assets/audio/ambience/riverside-village-day-ambience.mp3` | Daytime water-and-bird bed for the northern-village ferry exploration | Stream Water with Birds | loswin23 | https://pixabay.com/sound-effects/nature-stream-water-with-birds-459511/ | + +The 50-second source was reduced to a 32-second 44.1 kHz stereo MP3 loop. A +two-second end-to-start equal-power blend avoids a hard seam, and the encoded +track is balanced to -24.03 LUFS at 128 kbps. The final MP3 retains the title, +creator, Pixabay Content License, and source URL in its metadata. + ## Battlefield Ambience and Tactical Cues The source files in this section were downloaded from their official Pixabay detail pages on 2026-07-21. They use the Pixabay Content License linked above. diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index c8ae84e..1298da2 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -145,9 +145,36 @@ RPG로 다듬는다. - 오래된 저장은 그대로 읽고, 오염된 관계·무장 참조는 제거하며, 전투 저장 재개와 두 번째 전투 재도전에서도 보너스를 중복 가산하지 않는다. -다음 초반 묶음은 정보 수집·장비 구매·동료 대화 가운데 무엇을 먼저 -선택했는지가 출전 브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오는 -경로를 감사한다. +### 완료한 6차 묶음: 첫 승리 뒤 직접 걷는 정찰 + +- 첫 승리 뒤 정적인 방문 카드 대신 유비를 직접 움직여 간옹에게 가고, + 강가 매복과 마을 구호 중 다음 추격전의 정찰 우선순위를 정한다. +- 유비·간옹을 전투용 말이 아닌 별도의 SD 이동 인물로 보여 주고, + 대화창은 전신 대신 얼굴 중심 초상을 사용한다. +- 목표 인물 한 번 클릭으로 장애물을 피해 대화 거리까지 이동하며, NPC는 + 순간이동하지 않고 제자리에서 유비 쪽만 바라본다. +- 선택은 공명·물자 보상에 그치지 않고 두 번째 전투의 배치 정보와 개전 + 대사로 이어진다. +- 이동, 거리 제한, 선택 저장, 중복 보상 방지, 군영 복귀를 + 1920x1080 브라우저에서 자동 검증한다. + +### 완료한 7차 묶음: 두 번째 승리의 현장 수습과 전투 회수 + +- 두 번째 승리 직후 북쪽 마을과 나루를 직접 걸으며 관우 → 사공 → 장비 → + 간옹 순서로 현장을 확인한다. +- 첫 정찰에서 무엇을 택했는지와 실제로 마을 보호·신속 격파를 달성했는지 + 함께 회고해, 성공을 거짓으로 칭찬하거나 실패를 잊지 않는다. +- 세 현장 확인은 즉시 저장된다. 군영으로 돌아갔다가 다시 들어와도 다음 + 조사 지점부터 이어지며 최종 방침을 중복 선택할 수 없다. +- 마을 보급을 택하면 세 번째 전투에서 유비가 휴대 콩 1개를 더 받고, + 나루 추적을 택하면 배치 단계에서 전령 마원 한 명의 첫 움직임만 먼저 + 확인한다. +- 낮 강변 환경음, 지면별 발소리, 목표 완료의 시각·음향 신호를 함께 + 사용해 소리를 듣지 못해도 진행 상태를 이해할 수 있게 한다. + +다음 초반 묶음은 세 번째 군영부터 반복되는 방문·대화·상점 흐름을 감사해 +정보 수집, 장비 구매, 동료 대화 가운데 무엇을 먼저 택했는지가 출전 +브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오게 한다. ## 장기 개선 순서 @@ -203,8 +230,19 @@ RPG로 다듬는다. 명확한 정보와 제한된 규칙으로 깊은 전술 선택 만들기 - [Nintendo: Fire Emblem Three Houses 101](https://www.nintendo.com/au/news-and-articles/fire-emblem-three-houses-101/): 자유 탐색·관계 대화와 출전 인원·장비·정찰을 한 전투 준비 순환으로 연결 +- [Supergiant Games: Hades FAQ](https://www.supergiantgames.com/blog/hades-faq/): + 반복 전투에서도 새 인물 사건과 이야기 상태가 계속 드러나는 거점 순환 +- [Supergiant Games: Hades v1.0 업데이트](https://www.supergiantgames.com/blog/hades-updates/): + 맥락별 대사·조사 지점·초상·환경음과 관계 사건의 조건·우선순위를 함께 + 다듬어 반복 귀환의 변화를 유지 - [Nintendo: Fire Emblem Engage 전투와 유대](https://www.nintendo.com/ph/switch/ayfn/system/battle.html): 함께 싸우고 회복한 경험을 거점 대화와 다음 전투의 인접 지원으로 회수 +- [Xbox Accessibility Guideline 103](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/103): + 목표·상호작용·위험 같은 핵심 신호를 시각과 소리 가운데 하나에만 맡기지 + 않고 보조 감각 채널로 함께 전달 +- [Xbox Accessibility Guideline 105](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/105): + 음악·효과·환경음을 분리 조절하고 중요한 정보음이 배경음에 묻히지 않게 + 설계 - [Darkest Dungeon Affliction System, Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-i-darkest-dungeon-s-i-affliction-system): 원정의 결과가 귀환 뒤 인물별 준비 선택으로 이어지고 수치 결과는 명확히 공개 - [Into the Breach Design Postmortem PDF](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf): diff --git a/docs/visual-asset-prologue-exploration-report.md b/docs/visual-asset-prologue-exploration-report.md index b989c14..64a50bc 100644 --- a/docs/visual-asset-prologue-exploration-report.md +++ b/docs/visual-asset-prologue-exploration-report.md @@ -1,20 +1,22 @@ -# Prologue exploration visual asset report +# Exploration visual asset report -Generated on 2026-07-26 with the built-in image generation workflow. These are -original project assets; no commercial-game screenshot, logo, or third-party -character artwork is included in the runtime files. +Generated on 2026-07-26 and extended on 2026-07-27 with the built-in image +generation workflow. These are original project assets; no commercial-game +screenshot, logo, or third-party character artwork is included in the runtime +files. ## Runtime assets | Role | Project assets | Runtime format | | --- | --- | --- | | 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 | | 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 | | Prologue NPC dialogue portraits | `src/assets/images/portraits/zhuo-*-yellow-turban.webp`, `zou-jing-yellow-turban.webp` | 1254×1254 WebP | -The two backgrounds reserve the exact top, right, and bottom HUD regions used -by the 1920×1080 desktop layout. The playable portion was aligned to the +The three 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. Each exploration character sheet contains 192×192 frames: four direction rows @@ -38,6 +40,22 @@ notice board, peach orchard, and north gate. The camp variant required the north gate, command tent, scout tent, sleeping tent, supply area, training area, and central fire. +The second-victory relief variant used the first exploration background as a +camera-and-style reference and the second battle map as a spatial reference. +Its final built-in generation request was: + +> Original late-Eastern-Han riverside village after victory, with a shallow +> river and ferry to the left, a broad central recovery road and courtyard, +> damaged farmhouses in the upper-right, a north road toward Guangzong, and a +> southern camp exit. Leave readable open ground for six small SD actors and +> the fixed desktop HUD, under clear warm late-afternoon light. No people, +> text, UI, logo, watermark, modern object, copied character, or copyrighted +> game design. + +The generated master was converted to an opaque 1920×1080 WebP with +`scripts/build-exploration-background.py`; only the optimized runtime asset is +tracked. + ### Dialogue portraits > High-resolution realistic historical painting, late-Eastern-Han clothing, @@ -86,7 +104,7 @@ runtime sheet is shipped. ## Optimization and verification -- Backgrounds are limited to 2 MiB each and 4 MiB combined. +- Backgrounds are limited to 2 MiB each and 6 MiB combined. - SD sheets use transparent WebP and remain under 0.6 MiB each. - New NPC portraits were converted from generated PNG masters to high-quality WebP, reducing the five-file total from about 11.6 MiB to about 1.3 MiB. diff --git a/package.json b/package.json index 7bfa876..fd09312 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,10 @@ "verify:first-battle-camaraderie": "node scripts/verify-first-battle-camaraderie-memory.mjs", "verify:first-pursuit-scout": "node scripts/verify-first-pursuit-scout-memory.mjs", "verify:first-pursuit-camp:browser": "node scripts/verify-first-pursuit-camp-exploration-browser.mjs", + "verify:second-battle-relief": "node scripts/verify-second-battle-relief-exploration.mjs", + "verify:second-battle-relief:browser": "node scripts/verify-second-battle-relief-exploration-browser.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", + "verify:exploration-backgrounds": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", "verify:prologue-village:browser": "node scripts/verify-prologue-village-browser.mjs", @@ -70,7 +73,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/build-exploration-background.py b/scripts/build-exploration-background.py new file mode 100644 index 0000000..08287ef --- /dev/null +++ b/scripts/build-exploration-background.py @@ -0,0 +1,60 @@ +"""Build an opaque, desktop-sized WebP exploration background.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image, ImageOps + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--quality", type=int, default=88) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.width <= 0 or args.height <= 0: + raise ValueError("Output dimensions must be positive.") + if not 1 <= args.quality <= 100: + raise ValueError("WebP quality must be between 1 and 100.") + + output_path = args.output.resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + with Image.open(args.input) as source: + rgb_source = ImageOps.exif_transpose(source).convert("RGB") + resized = ImageOps.fit( + rgb_source, + (args.width, args.height), + method=Image.Resampling.LANCZOS, + centering=(0.5, 0.5), + ) + resized.save( + output_path, + format="WEBP", + quality=args.quality, + method=6, + exact=False, + ) + + with Image.open(output_path) as result: + if result.size != (args.width, args.height) or result.mode != "RGB": + raise RuntimeError( + f"Unexpected output: {result.size[0]}x{result.size[1]} {result.mode}" + ) + + print( + f"Built {output_path} ({args.width}x{args.height}, " + f"{output_path.stat().st_size} bytes)." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/build-riverside-day-ambience.py b/scripts/build-riverside-day-ambience.py new file mode 100644 index 0000000..d5833ef --- /dev/null +++ b/scripts/build-riverside-day-ambience.py @@ -0,0 +1,101 @@ +"""Prepare the licensed daytime riverside ambience used by exploration.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess + + +SOURCE_URL = ( + "https://pixabay.com/sound-effects/" + "nature-stream-water-with-birds-459511/" +) +TARGET_DURATION_SECONDS = 32 +SOURCE_OFFSET_SECONDS = 7 +LOOP_CROSSFADE_SECONDS = 2 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + source_path = args.input.resolve() + output_path = args.output.resolve() + if not source_path.is_file(): + raise FileNotFoundError(source_path) + + ffmpeg = os.environ.get("FFMPEG_PATH") or shutil.which("ffmpeg") + if not ffmpeg: + raise RuntimeError("ffmpeg is required to build the ambience track.") + + output_path.parent.mkdir(parents=True, exist_ok=True) + loop_end = SOURCE_OFFSET_SECONDS + TARGET_DURATION_SECONDS + head_end = SOURCE_OFFSET_SECONDS + LOOP_CROSSFADE_SECONDS + body_start = head_end + tail_start = loop_end + tail_end = loop_end + LOOP_CROSSFADE_SECONDS + filter_graph = ( + "[0:a]asplit=3[tailSource][headSource][bodySource];" + f"[tailSource]atrim=start={tail_start}:end={tail_end}," + f"asetpts=PTS-STARTPTS,afade=t=out:st=0:" + f"d={LOOP_CROSSFADE_SECONDS}[tail];" + f"[headSource]atrim=start={SOURCE_OFFSET_SECONDS}:end={head_end}," + f"asetpts=PTS-STARTPTS,afade=t=in:st=0:" + f"d={LOOP_CROSSFADE_SECONDS}[head];" + "[tail][head]amix=inputs=2:normalize=0:duration=first[seam];" + f"[bodySource]atrim=start={body_start}:end={loop_end}," + "asetpts=PTS-STARTPTS[body];" + "[seam][body]concat=n=2:v=0:a=1," + "loudnorm=I=-24:TP=-3:LRA=7[out]" + ) + command = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(source_path), + "-filter_complex", + filter_graph, + "-map", + "[out]", + "-ar", + "44100", + "-ac", + "2", + "-codec:a", + "libmp3lame", + "-b:a", + "128k", + "-metadata", + "title=Riverside Village Day Ambience", + "-metadata", + "artist=loswin23", + "-metadata", + ( + "comment=Derived from Stream Water with Birds; " + f"Pixabay Content License; {SOURCE_URL}" + ), + str(output_path), + ] + subprocess.run(command, check=True) + + if output_path.stat().st_size <= 0: + raise RuntimeError(f"Empty ambience output: {output_path}") + print( + f"Built {output_path} ({TARGET_DURATION_SECONDS}s target, " + f"{output_path.stat().st_size} bytes)." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-audio-asset-data.mjs b/scripts/verify-audio-asset-data.mjs index eabb2f8..60abc6f 100644 --- a/scripts/verify-audio-asset-data.mjs +++ b/scripts/verify-audio-asset-data.mjs @@ -8,11 +8,15 @@ const sourceFilesToScan = [ 'src/game/audio/SoundDirector.ts', 'src/game/scenes/BattleScene.ts', 'src/game/scenes/CampScene.ts', + 'src/game/scenes/CampVisitExplorationScene.ts', 'src/game/scenes/EndingScene.ts', 'src/game/scenes/StoryScene.ts', 'src/game/scenes/TitleScene.ts' ]; const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire']; +const requiredDaytimeExplorationAmbienceKeys = [ + 'riverside-village-day-ambience' +]; const requiredTacticalCueKeys = [ 'ally-turn', 'enemy-turn', @@ -68,6 +72,14 @@ const battlefieldSourceRecords = [ source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/' } ]; +const riversideDaySourceRecord = { + trackKey: 'riverside-village-day-ambience', + projectFile: + 'src/assets/audio/ambience/riverside-village-day-ambience.mp3', + source: + 'https://pixabay.com/sound-effects/nature-stream-water-with-birds-459511/', + creator: 'loswin23' +}; const narrativeSourceRecord = { projectFile: 'src/assets/audio/sfx/story-page-turn.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/', @@ -130,10 +142,17 @@ try { validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience'); validateTrackMap(errors, 'effect', effectTracks, 'sfx'); validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys); + validateRequiredTrackKeys( + errors, + 'daytime exploration ambience', + ambienceTracks, + requiredDaytimeExplorationAmbienceKeys + ); validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys); validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys); validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys); validateBattlefieldSourceDocumentation(errors); + validateRiversideDaySource(errors, ambienceTracks); validateNarrativeSourceDocumentation(errors); validateSemanticFeedbackSourceDocumentation(errors); validateMovementSourceDocumentation(errors); @@ -465,6 +484,70 @@ function linearGainDb(gain) { return 20 * Math.log10(Math.max(0.0001, gain)); } +function validateRiversideDaySource(errors, ambienceTracks) { + const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); + const requiredDetails = [ + '2026-07-27', + riversideDaySourceRecord.projectFile, + riversideDaySourceRecord.source, + 'Stream Water with Birds', + riversideDaySourceRecord.creator, + '32-second', + '44.1 kHz stereo MP3', + 'two-second end-to-start', + '-24.03 LUFS', + '128 kbps' + ]; + requiredDetails.forEach((detail) => { + if (!docs.includes(detail)) { + errors.push( + `docs/audio-sources.md is missing daytime riverside detail "${detail}"` + ); + } + }); + + if (!audioInspectionToolsAvailable(errors)) { + return; + } + const url = ambienceTracks[riversideDaySourceRecord.trackKey]; + const path = url ? assetUrlToPath(url) : undefined; + if (!path) { + errors.push('The daytime riverside ambience must resolve to a project asset.'); + return; + } + const probe = probeAudio(path, errors, riversideDaySourceRecord.trackKey); + if (!probe) { + return; + } + if (probe.duration < 31.9 || probe.duration > 32.2) { + errors.push( + `${riversideDaySourceRecord.trackKey} must remain a 32-second loop; ` + + `received ${probe.duration.toFixed(3)} seconds` + ); + } + if (probe.sampleRate !== 44_100 || probe.channels !== 2) { + errors.push( + `${riversideDaySourceRecord.trackKey} must be 44.1 kHz stereo; ` + + `received ${probe.sampleRate} Hz/${probe.channels} channels` + ); + } + if (!probe.artist.includes(riversideDaySourceRecord.creator)) { + errors.push( + `${riversideDaySourceRecord.trackKey} metadata must retain creator ` + + `"${riversideDaySourceRecord.creator}"` + ); + } + if ( + !probe.comment.includes('Pixabay Content License') || + !probe.comment.includes(riversideDaySourceRecord.source) + ) { + errors.push( + `${riversideDaySourceRecord.trackKey} metadata must retain its Pixabay ` + + 'license and source URL' + ); + } +} + function validateBattlefieldSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); if (!docs.includes('2026-07-21')) { diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 7a13ebb..f5f570b 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -16,6 +16,10 @@ try { const { firstPursuitScoutVisitDefinition } = await server.ssrLoadModule( '/src/game/data/firstPursuitScoutVisit.ts' ); + const { secondBattleReliefExplorationDefinition } = + await server.ssrLoadModule( + '/src/game/data/secondBattleReliefExploration.ts' + ); const scenarioData = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { isCampaignStep } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const itemRewardArrays = collectItemRewardArrays(source); @@ -26,9 +30,12 @@ try { const knownBondsById = collectKnownBondsById(battleScenarios, scenarioData); const dialogueEvents = collectCampEvents(source, 'campDialogues'); const visitEvents = collectCampEvents(source, 'campVisits').map((event) => - materializeCanonicalFirstPursuitVisit( - event, - firstPursuitScoutVisitDefinition + materializeCanonicalSecondBattleReliefVisit( + materializeCanonicalFirstPursuitVisit( + event, + firstPursuitScoutVisitDefinition + ), + secondBattleReliefExplorationDefinition ) ); const campaignStepReferences = @@ -355,6 +362,32 @@ function materializeCanonicalFirstPursuitVisit(event, definition) { }; } +function materializeCanonicalSecondBattleReliefVisit(event, definition) { + if (!event.body.includes('secondBattleReliefExplorationDefinition')) { + return event; + } + + const quote = (value) => + `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; + const choiceSource = definition.choices + .map( + (choice) => + `{ id: ${quote(choice.id)}, bondId: ${quote(choice.bondId)}, ` + + `bondExp: ${choice.bondExp}, ` + + `itemRewards: [${choice.itemRewards.map(quote).join(', ')}] }` + ) + .join(', '); + const canonicalSource = + `{ id: ${quote(definition.id)}, ` + + 'availableAfterBattleIds: [campBattleIds.second], ' + + "availableDuringSteps: ['second-camp'], " + + `choices: [${choiceSource}], `; + return { + ...event, + body: canonicalSource + event.body.slice(1) + }; +} + function validateCampEventCollection(events, collectionName, campBattleIdKeys, isCampaignStep) { const seenIds = new Map(); let campaignStepReferenceCount = 0; @@ -433,7 +466,7 @@ function validateCampBondReferences(events, collectionName, knownBondsById, opti return; } bondReferenceCount += bondIdEntries.length; - if (bondIdEntries.length > 1) { + if (bondIdEntries.length > 1 && collectionName !== 'campVisits') { errors.push(`${sourcePath}:${lineNumber(event.offset)} ${context} has multiple bondId properties`); } @@ -469,10 +502,20 @@ function validateRewardBondLinks(events, collectionName, rewardPropertyName) { return; } rewardReferenceCount += rewardEntries.length; - if (collectStringProperties(event.body, 'bondId').length === 0) { + const bondIdCount = collectStringProperties(event.body, 'bondId').length; + if (bondIdCount === 0) { errors.push( `${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ${rewardPropertyName} rewards but no bondId` ); + } else if ( + bondIdCount > 1 && + bondIdCount !== rewardEntries.length + ) { + errors.push( + `${sourcePath}:${lineNumber(rewardEntries[0].offset)} ${context} has ` + + `${rewardEntries.length} ${rewardPropertyName} rewards but ` + + `${bondIdCount} per-choice bondIds` + ); } }); return rewardReferenceCount; diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs index 77ead1a..01a5e71 100644 --- a/scripts/verify-city-stay-browser.mjs +++ b/scripts/verify-city-stay-browser.mjs @@ -80,7 +80,8 @@ try { verifyExplorationLayout(city, xuzhouCase.cityStayId); await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png'); - await verifyKeyboardMovement(page); + const movementProbe = await verifyKeyboardMovement(page); + await verifyPointerMovement(page, movementProbe); await verifyDistantInteractionIsBlocked(page); const informationResult = await verifyInformationReward( @@ -126,7 +127,7 @@ try { console.log( `Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + - `DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard and world-pointer movement, 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.' ); @@ -442,6 +443,42 @@ async function verifyKeyboardMovement(page) { })}` ); assert.equal(after.player.moving, false); + return { + start: { x: before.player.x, y: before.player.y }, + end: { x: after.player.x, y: after.player.y } + }; +} + +async function verifyPointerMovement(page, movementProbe) { + await clickScenePoint(page, 'CityStayScene', movementProbe.start); + await waitForPlayerAt(page, movementProbe.start); + const returned = await readCity(page); + assert( + returned.player.x - movementProbe.end.x >= 85, + `Expected world-pointer movement to return Liu Bei right: ${JSON.stringify({ + movementProbe, + returned: returned.player + })}` + ); + assert( + Math.abs(returned.player.y - movementProbe.start.y) <= 3, + `Expected world-pointer movement to retain Y: ${JSON.stringify({ + movementProbe, + returned: returned.player + })}` + ); + + await clickScenePoint(page, 'CityStayScene', movementProbe.end); + await waitForPlayerAt(page, movementProbe.end); + const restored = await readCity(page); + assert( + Math.abs(restored.player.x - movementProbe.end.x) <= 9 && + Math.abs(restored.player.y - movementProbe.end.y) <= 9, + `Expected the pointer probe to restore the distant interaction position: ${JSON.stringify({ + expected: movementProbe.end, + restored: restored.player + })}` + ); } async function verifyDistantInteractionIsBlocked(page) { @@ -1028,6 +1065,40 @@ async function clickSceneBounds(page, sceneKey, bounds) { await page.mouse.click(point.x, point.y); } +async function clickScenePoint(page, sceneKey, scenePoint) { + const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => { + const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds || !requestedPoint) { + return null; + } + return { + x: canvasBounds.left + + requestedPoint.x * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + + requestedPoint.y * canvasBounds.height / scene.scale.height + }; + }, { + requestedSceneKey: sceneKey, + requestedPoint: scenePoint + }); + assert(point && Number.isFinite(point.x) && Number.isFinite(point.y)); + await page.mouse.click(point.x, point.y); +} + +async function waitForPlayerAt(page, target) { + await page.waitForFunction(({ x, y }) => { + const player = window.__HEROS_DEBUG__?.cityStay?.()?.player; + return Boolean( + player && + Math.abs(player.x - x) <= 9 && + Math.abs(player.y - y) <= 9 && + player.moving === false + ); + }, target); +} + async function captureStableScreenshot(page, path) { await page.waitForTimeout(260); const loopSlept = await page.evaluate(() => { diff --git a/scripts/verify-prologue-exploration-asset-data.mjs b/scripts/verify-prologue-exploration-asset-data.mjs index 91ea6a2..ec68c3a 100644 --- a/scripts/verify-prologue-exploration-asset-data.mjs +++ b/scripts/verify-prologue-exploration-asset-data.mjs @@ -4,12 +4,13 @@ import { join } from 'node:path'; const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration'); const expectedAssets = [ 'prologue-village.webp', - 'prologue-militia-camp.webp' + 'prologue-militia-camp.webp', + 'second-pursuit-village-ferry.webp' ]; const expectedWidth = 1920; const expectedHeight = 1080; const maximumAssetBytes = 2 * 1024 * 1024; -const maximumTotalBytes = 4 * 1024 * 1024; +const maximumTotalBytes = 6 * 1024 * 1024; const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i; const errors = []; let totalBytes = 0; @@ -53,7 +54,7 @@ if (totalBytes > maximumTotalBytes) { } if (errors.length > 0) { - console.error(`Prologue exploration asset verification failed with ${errors.length} issue(s):`); + console.error(`Exploration background asset verification failed with ${errors.length} issue(s):`); errors.forEach((error) => console.error(`- ${error}`)); process.exitCode = 1; } else { @@ -90,7 +91,7 @@ function validateDirectoryCoverage(validationErrors) { if (!expectedRasterFiles.includes(fileName)) { validationErrors.push( `${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` + - `only the two optimized runtime WebP backgrounds should be tracked here` + `only the optimized runtime WebP backgrounds should be tracked here` ); } } diff --git a/scripts/verify-second-battle-relief-exploration-browser.mjs b/scripts/verify-second-battle-relief-exploration-browser.mjs new file mode 100644 index 0000000..e60b2e5 --- /dev/null +++ b/scripts/verify-second-battle-relief-exploration-browser.mjs @@ -0,0 +1,1129 @@ +import assert from 'node:assert/strict'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const explorationSceneKey = 'CampVisitExplorationScene'; +const sourceBattleId = 'second-battle-yellow-turban-pursuit'; +const targetBattleId = 'third-battle-guangzong-road'; +const visitId = 'second-pursuit-aftermath-relief'; +const firstScoutVisitId = 'first-pursuit-scout-tent'; +const objectiveIds = [ + 'reassure-northern-village', + 'question-ferry-witness', + 'inspect-messenger-trail' +]; +const objectiveProgressIds = objectiveIds.map( + (objectiveId) => `${visitId}:objective:${objectiveId}` +); +const expectedActors = { + 'guan-yu-village': { + x: 1050, + y: 455, + textureKey: 'exploration-guan-yu' + }, + 'north-ferry-boatman': { + x: 360, + y: 470, + textureKey: 'exploration-zhuo-villager' + }, + 'zhang-fei-ferry': { + x: 540, + y: 675, + textureKey: 'exploration-zhang-fei' + }, + 'jian-yong-records': { + x: 790, + y: 690, + textureKey: 'exploration-jian-yong' + } +}; +const rendererFixtures = { + canvas: { + renderer: 'canvas', + expectedRendererType: 1, + firstScoutChoiceId: 'mark-village-relief', + expectedScoutFocus: 'village', + reliefChoiceId: 'restore-northern-village', + rewardItemLabel: '콩', + bondId: 'liu-bei__guan-yu', + effectKind: 'village-supply-line', + expectedEffectText: '다음 전투 · 유비 후방 지원 · 휴대 콩 +1' + }, + webgl: { + renderer: 'webgl', + expectedRendererType: 2, + firstScoutChoiceId: 'trace-river-ambush', + expectedScoutFocus: 'river', + reliefChoiceId: 'trace-ferry-messenger', + rewardItemLabel: '상처약', + bondId: 'liu-bei__jian-yong', + effectKind: 'ferry-messenger-intel', + expectedEffectText: + '다음 전투 · 장비 측면 추천 · 전령 마원의 첫 행동 표시' + } +}; + +const requestedRenderer = + cliOption('renderer') ?? + process.env.VERIFY_SECOND_BATTLE_RELIEF_RENDERER ?? + 'both'; +const renderers = + requestedRenderer === 'both' + ? ['canvas', 'webgl'] + : [requestedRenderer]; +renderers.forEach((renderer) => { + assert( + renderer in rendererFixtures, + `Unknown renderer "${renderer}". Use canvas, webgl, or both.` + ); +}); + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'spa' +}); +let browser; + +try { + mkdirSync('dist', { recursive: true }); + await server.listen(); + const address = server.httpServer?.address(); + assert( + address && typeof address !== 'string', + 'Expected a local Vite verification server.' + ); + const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; + browser = await chromium.launch({ + headless: process.env.VERIFY_SECOND_BATTLE_RELIEF_HEADLESS !== '0' + }); + + for (const renderer of renderers) { + await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); + } + + console.log( + `Second-battle relief browser verification passed for ${renderers.join( + ' + ' + )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.` + ); +} finally { + await browser?.close(); + await server.close(); +} + +async function verifyRenderer(browser, baseUrl, fixture) { + const context = await browser.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 { + const url = new URL(baseUrl); + url.searchParams.set('debug', '1'); + url.searchParams.set('renderer', fixture.renderer); + await page.goto(url.toString(), { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, fixture); + + const seeded = await seedSecondVictoryCamp(page, fixture); + assert.equal(seeded.step, 'second-camp'); + assert.equal(seeded.latestBattleId, sourceBattleId); + assert.equal(seeded.reportBattleId, sourceBattleId); + assert.equal(seeded.settlementOutcome, 'victory'); + assert.equal(seeded.firstScoutChoiceId, fixture.firstScoutChoiceId); + assert.equal(seeded.reliefCompleted, false); + + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + const campBefore = await waitForCampReliefCta(page); + assert.equal(campBefore.activeTab, 'visit'); + assert.equal(campBefore.campaign.step, 'second-camp'); + assert.equal(campBefore.campBattleId, sourceBattleId); + assert.equal( + campBefore.secondBattleReliefExploration.mode, + 'walkable-relief' + ); + assert.equal( + campBefore.secondBattleReliefExploration.explorationInteractive, + true + ); + assert( + campBefore.secondBattleReliefExploration.explorationButtonBounds, + 'CampScene must expose a clickable second-relief CTA.' + ); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-camp-cta.png` + ); + + await clickSceneBounds( + page, + 'CampScene', + campBefore.secondBattleReliefExploration.explorationButtonBounds + ); + await waitForExplorationReady(page); + + let exploration = await readExploration(page); + assert.equal(exploration.locationId, visitId); + assert.equal(exploration.campaignStep, 'second-camp'); + assert.equal( + exploration.background.key, + 'second-pursuit-village-ferry-background' + ); + assert.equal(exploration.background.ready, true); + assert.equal(exploration.background.fallback, false); + assert.equal( + exploration.background.sourceWidth, + desktopBrowserViewport.width + ); + assert.equal( + exploration.background.sourceHeight, + desktopBrowserViewport.height + ); + assert.equal(exploration.requiredTexturesReady, true); + assert.equal(exploration.visit.completed, false); + assert.deepEqual(exploration.visit.completedObjectiveIds, []); + assert.equal( + exploration.visit.nextObjectiveId, + objectiveIds[0] + ); + assert.equal(exploration.storyContext.sourceBattleId, sourceBattleId); + assert.equal(exploration.storyContext.sourceBattleTurn, 18); + assert.equal(exploration.storyContext.villageObjectiveAchieved, true); + assert.equal(exploration.storyContext.quickObjectiveAchieved, true); + assert.equal( + exploration.storyContext.scoutReview.choiceId, + fixture.firstScoutChoiceId + ); + assert.equal( + exploration.storyContext.scoutReview.focus, + fixture.expectedScoutFocus + ); + assert.equal( + exploration.storyContext.scoutReview.outcome, + 'confirmed' + ); + assert.match( + exploration.storyContext.arrivalLine, + /북쪽 마을|마을/, + 'The exploration must carry the second-battle outcome into its arrival context.' + ); + assertActorsFixed(exploration, `${fixture.renderer} initial`); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-initial.png` + ); + + const farProgress = [...exploration.visit.completedObjectiveIds]; + assert.equal(exploration.dialogue.active, false); + assert.equal(exploration.interaction.canInteract, false); + await page.keyboard.press('e'); + await page.waitForTimeout(180); + exploration = await readExploration(page); + assert.equal( + exploration.dialogue.active, + false, + 'Pressing E outside the interaction radius must not open dialogue.' + ); + assert.deepEqual( + exploration.visit.completedObjectiveIds, + farProgress, + 'A remote E press must not record a relief objective.' + ); + + const beforeKeyboard = { ...exploration.player }; + await page.keyboard.down('ArrowLeft'); + try { + await page.waitForFunction( + ({ startX }) => + window.__HEROS_DEBUG__ + ?.campVisitExploration?.() + ?.player?.x <= + startX - 35, + { startX: beforeKeyboard.x }, + { timeout: 5000 } + ); + } finally { + await page.keyboard.up('ArrowLeft'); + } + exploration = await readExploration(page); + assert( + exploration.player.x < beforeKeyboard.x - 30, + 'Arrow-key movement must move Liu Bei in the world.' + ); + + const beforePointer = { ...exploration.player }; + const pointerTarget = { + x: Math.min(1320, beforePointer.x + 120), + y: Math.max(700, beforePointer.y - 45) + }; + await clickScenePoint( + page, + explorationSceneKey, + pointerTarget.x, + pointerTarget.y + ); + await page.waitForFunction( + ({ startX, startY }) => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.movement?.target !== null && + Math.hypot( + state.player.x - startX, + state.player.y - startY + ) >= 8 + ); + }, + { startX: beforePointer.x, startY: beforePointer.y }, + { timeout: 5000 } + ); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.()?.movement + ?.target === null, + undefined, + { timeout: 8000 } + ); + exploration = await readExploration(page); + assert( + Math.hypot( + exploration.player.x - beforePointer.x, + exploration.player.y - beforePointer.y + ) >= 40, + 'A world pointer click must move Liu Bei toward the selected ground.' + ); + assertActorsFixed(exploration, `${fixture.renderer} after movement`); + + const earlyChoice = await page.evaluate( + ({ key, choiceId }) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.debugChooseVisitChoice?.(choiceId) ?? null, + { key: explorationSceneKey, choiceId: fixture.reliefChoiceId } + ); + assert.equal( + earlyChoice, + false, + 'The final relief choice must remain locked before all field objectives.' + ); + + await interactWithActor(page, 'north-ferry-boatman'); + exploration = await readExploration(page); + assert.equal(exploration.dialogue.sourceNpcId, 'north-ferry-boatman'); + assert.equal(exploration.dialogue.speaker, '현장 기록'); + await advanceDialogueUntilClosed(page); + exploration = await readExploration(page); + assert.deepEqual( + exploration.visit.completedObjectiveIds, + [], + 'Interacting with the ferry witness before Guan Yu must remain sequence-locked.' + ); + + await interactWithActor(page, 'guan-yu-village'); + exploration = await readExploration(page); + assert.equal(exploration.dialogue.sourceNpcId, 'guan-yu-village'); + assert( + exploration.dialogue.totalLines >= 6, + 'The first objective dialogue must include intro, battle-result context, scout review, and authored actor lines.' + ); + assert.equal(exploration.dialogue.portraitVisible, true); + assert.equal(exploration.dialogue.portraitFrameVisible, true); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-intro-dialogue.png` + ); + await advanceDialogueUntilClosed(page); + await waitForObjectiveProgress(page, [objectiveIds[0]]); + + const partialCampaign = await readCampaign(page); + assert.equal( + partialCampaign.completedCampVisits.filter( + (candidate) => candidate === objectiveProgressIds[0] + ).length, + 1, + 'The first relief objective must be persisted exactly once before reload.' + ); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-partial.png` + ); + + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, fixture); + await page.evaluate(async (requestedVisitId) => { + await window.__HEROS_DEBUG__.goToCampVisitExploration( + requestedVisitId + ); + }, visitId); + await waitForExplorationReady(page); + exploration = await readExploration(page); + assert.deepEqual( + exploration.visit.completedObjectiveIds, + [objectiveIds[0]] + ); + assert.equal(exploration.visit.nextObjectiveId, objectiveIds[1]); + assertActorsFixed(exploration, `${fixture.renderer} resumed`); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-resumed.png` + ); + + await completeActorObjective( + page, + 'north-ferry-boatman', + objectiveIds.slice(0, 2) + ); + await completeActorObjective( + page, + 'zhang-fei-ferry', + objectiveIds + ); + exploration = await readExploration(page); + assert.equal(exploration.visit.choiceReady, true); + assert.equal( + exploration.visit.nextObjectiveId, + 'set-guangzong-priority' + ); + assertActorsFixed(exploration, `${fixture.renderer} objectives complete`); + + await interactWithActor(page, 'jian-yong-records'); + await advanceDialogueUntilChoice(page); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.()?.choice + ?.ready === true + ); + exploration = await readExploration(page); + assert.equal(exploration.choice.open, true); + assert.deepEqual( + exploration.choice.choices.map(({ id }) => id), + ['restore-northern-village', 'trace-ferry-messenger'] + ); + const selectedChoice = exploration.choice.choices.find( + (choice) => choice.id === fixture.reliefChoiceId + ); + assert(selectedChoice?.interactive); + assert.match(selectedChoice.rewardText, /공명 \+10/); + assert.equal( + selectedChoice.effectText, + fixture.expectedEffectText, + 'The selected relief card must state its exact next-battle effect.' + ); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-choice.png` + ); + + const beforeReward = await readCampaign(page); + const bondBefore = campaignBond(beforeReward, fixture.bondId); + assert(bondBefore, `Missing seeded bond ${fixture.bondId}.`); + await clickSceneBounds( + page, + explorationSceneKey, + selectedChoice.bounds + ); + await page.waitForFunction( + (requestedVisitId) => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.visit?.id === requestedVisitId && + state.visit.completed === true && + state.dialogue.active === true + ); + }, + visitId + ); + exploration = await readExploration(page); + assert.equal(exploration.visit.choiceId, fixture.reliefChoiceId); + assert.equal(exploration.dialogue.sourceNpcId, 'jian-yong-records'); + assertActorsFixed(exploration, `${fixture.renderer} completed`); + const afterReward = await readCampaign(page); + assertRewardAppliedOnce(beforeReward, afterReward, bondBefore, fixture); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-completed.png` + ); + + const relevantAfterReward = relevantProgress(afterReward, fixture); + const duplicateResult = await page.evaluate( + ({ key, choiceId }) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.debugChooseVisitChoice?.(choiceId) ?? null, + { key: explorationSceneKey, choiceId: fixture.reliefChoiceId } + ); + assert.equal( + duplicateResult, + false, + 'A completed relief visit must reject duplicate choice rewards.' + ); + assert.deepEqual( + relevantProgress(await readCampaign(page), fixture), + relevantAfterReward, + 'A duplicate completion attempt must not change rewards or memory.' + ); + + await advanceDialogueUntilClosed(page); + const teleportedToExit = await page.evaluate((key) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.debugTeleportTo?.('camp-exit') ?? false + , explorationSceneKey); + assert.equal(teleportedToExit, true); + await page.keyboard.press('e'); + const returnedCamp = await waitForCampReturn(page); + assert.equal(returnedCamp.campaign.step, 'second-camp'); + assert.equal( + returnedCamp.secondBattleReliefExploration.completed, + true + ); + assert.equal( + returnedCamp.secondBattleReliefExploration.choiceId, + fixture.reliefChoiceId + ); + assert.equal( + returnedCamp.campaign.completedCampVisits.filter( + (candidate) => candidate === visitId + ).length, + 1 + ); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-camp-return.png` + ); + + await page.evaluate(async (battleId) => { + await window.__HEROS_DEBUG__.goToBattle(battleId); + }, targetBattleId); + const battle = await waitForThirdBattleDeployment(page); + const memory = battle.secondBattleReliefMemory; + assert.equal(memory.active, true); + assert.equal(memory.sourceBattleId, sourceBattleId); + assert.equal(memory.targetBattleId, targetBattleId); + assert.equal(memory.choiceId, fixture.reliefChoiceId); + assert.equal(memory.effectKind, fixture.effectKind); + assert.equal(memory.readiness, 'reinforced'); + assert.equal(memory.sourceObjectiveAchieved, true); + assert.equal(memory.openingLines.length, 3); + assert.match( + battle.battleHud?.deploymentPanel?.subtitleText ?? '', + /현장 수습/, + 'The deployment panel must surface the relief memory.' + ); + + if (fixture.effectKind === 'village-supply-line') { + assert.equal(memory.villageSupply.active, true); + assert.equal(memory.villageSupply.carrierUnitId, 'liu-bei'); + assert.equal(memory.villageSupply.recommendedRole, 'support'); + assert.equal(memory.villageSupply.itemId, 'bean'); + assert.equal(memory.villageSupply.carriedCount, 1); + assert.equal( + memory.villageSupply.battleStock, + battle.itemStocks['liu-bei'].bean + ); + assert.equal( + memory.villageSupply.battleStock, + 3, + 'The village choice must add one carried bean to Liu Bei’s normal stock.' + ); + assert.equal(battle.enemyIntentPreviews.length, 0); + } else { + assert.equal(memory.ferryIntel.active, true); + assert.equal(memory.ferryIntel.recommendedUnitId, 'zhang-fei'); + assert.equal(memory.ferryIntel.recommendedRole, 'flank'); + assert.equal( + memory.ferryIntel.trackedEnemyUnitId, + 'guangzong-leader-ma-yuan' + ); + assert.equal(memory.ferryIntel.revealTrackedRoute, true); + assert.equal( + memory.ferryIntel.forecastVisibleDuringDeployment, + true + ); + assert.equal( + battle.enemyIntentPreviews.length, + 1, + 'Ferry intelligence must reveal exactly the tracked messenger forecast during deployment.' + ); + assert.equal( + battle.enemyIntentPreviews[0].enemyId, + 'guangzong-leader-ma-yuan' + ); + assert.equal( + memory.ferryIntel.trackedIntent.enemyId, + 'guangzong-leader-ma-yuan' + ); + } + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-third-battle-deployment.png` + ); + + await page.evaluate(() => + window.__HEROS_GAME__?.scene + .getScene('BattleScene') + ?.confirmPreBattleDeployment?.() + ); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.battle?.(); + return ( + state?.battleId === 'third-battle-guangzong-road' && + state.phase === 'idle' && + state.activeBattleEvent?.key === 'opening' + ); + }, + undefined, + { timeout: 90000 } + ); + const openedBattle = await page.evaluate(() => + window.__HEROS_DEBUG__?.battle?.() + ); + const openingEventLines = openedBattle.activeBattleEvent?.lines ?? []; + memory.openingLines.forEach((line) => { + assert( + openingEventLines.includes(line), + `The opening event must display remembered relief line: ${line}; state=${JSON.stringify({ + activeBattleEvent: openedBattle.activeBattleEvent, + pendingBattleEvents: openedBattle.pendingBattleEvents, + triggeredBattleEvents: openedBattle.triggeredBattleEvents + })}` + ); + }); + await capture( + page, + `dist/verification-second-battle-relief-${fixture.renderer}-third-battle-opening.png` + ); + + assert.deepEqual( + pageErrors, + [], + `${fixture.renderer}: browser page errors: ${JSON.stringify( + pageErrors + )}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes('The AudioContext was not allowed to start') && + !message.includes('WebGL') + ), + [], + `${fixture.renderer}: browser console errors: ${JSON.stringify( + consoleErrors + )}` + ); + } finally { + await context.close(); + } +} + +async function seedSecondVictoryCamp(page, fixture) { + return page.evaluate( + async ({ + battleId, + scoutVisitId, + scoutChoiceId, + requestedVisitId + }) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[battleId]; + if (!scenario) { + throw new Error(`Missing battle scenario ${battleId}.`); + } + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 18, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: true, + status: 'done', + detail: '달성', + rewardGold: objective.rewardGold + })), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + + let campaign = campaignModule.getCampaignState(); + campaign.completedCampVisits = [ + ...new Set([ + ...campaign.completedCampVisits, + scoutVisitId + ]) + ]; + campaign.campVisitChoiceIds = { + ...campaign.campVisitChoiceIds, + [scoutVisitId]: scoutChoiceId + }; + if (campaign.firstBattleReport) { + campaign.firstBattleReport.completedCampVisits = [ + ...new Set([ + ...campaign.firstBattleReport.completedCampVisits, + scoutVisitId + ]) + ]; + } + campaign.dismissedVictoryRewardNoticeBattleIds = [ + ...new Set([ + ...campaign.dismissedVictoryRewardNoticeBattleIds, + battleId + ]) + ]; + campaignModule.setCampaignState(campaign); + campaignModule.completeCampaignAftermath(battleId); + campaign = campaignModule.getCampaignState(); + campaignModule.saveCampaignState(campaign, 1); + + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + settlementOutcome: + campaign.battleHistory[battleId]?.outcome ?? null, + firstScoutChoiceId: + campaign.campVisitChoiceIds[scoutVisitId] ?? null, + reliefCompleted: + campaign.completedCampVisits.includes(requestedVisitId) + }; + }, + { + battleId: sourceBattleId, + scoutVisitId: firstScoutVisitId, + scoutChoiceId: fixture.firstScoutChoiceId, + requestedVisitId: visitId + } + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function assertFhdViewport(page, fixture) { + const state = await page.evaluate(() => ({ + css: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + zoom: window.visualViewport?.scale ?? 1 + }, + rendererType: window.__HEROS_GAME__?.renderer?.type ?? null + })); + assert.deepEqual(state.css, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + zoom: 1 + }); + assert.equal( + state.rendererType, + fixture.expectedRendererType, + `${fixture.renderer}: Phaser must use the requested renderer.` + ); +} + +async function waitForCampReliefCta(page) { + await page.waitForFunction( + (requestedVisitId) => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + return ( + debug?.activeScenes?.().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.campaign?.step === 'second-camp' && + camp?.secondBattleReliefExploration?.visitId === + requestedVisitId && + camp.secondBattleReliefExploration.available === true && + camp.secondBattleReliefExploration.explorationInteractive === + true + ); + }, + visitId, + { timeout: 90000 } + ); + return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); +} + +async function waitForExplorationReady(page) { + await page.waitForFunction( + ({ key, requestedVisitId }) => { + const debug = window.__HEROS_DEBUG__; + const state = debug?.campVisitExploration?.(); + return ( + debug?.activeScenes?.().includes(key) && + state?.scene === key && + state.locationId === requestedVisitId && + state.ready === true + ); + }, + { key: explorationSceneKey, requestedVisitId: visitId }, + { timeout: 90000 } + ); +} + +async function waitForCampReturn(page) { + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + return ( + debug?.activeScenes?.().includes('CampScene') && + !debug + ?.activeScenes?.() + .includes('CampVisitExplorationScene') && + camp?.scene === 'CampScene' && + camp?.campaign?.step === 'second-camp' + ); + }, + undefined, + { timeout: 90000 } + ); + return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); +} + +async function waitForThirdBattleDeployment(page) { + await page.waitForFunction( + (battleId) => { + const state = window.__HEROS_DEBUG__?.battle?.(); + const assetsReady = + state?.combatAssets?.status === 'ready' || + state?.combatAssets?.status === 'degraded'; + return ( + state?.scene === 'BattleScene' && + state.battleId === battleId && + state.phase === 'deployment' && + assetsReady + ); + }, + targetBattleId, + { timeout: 90000 } + ); + return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); +} + +async function readExploration(page) { + return page.evaluate(() => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ); +} + +async function readCampaign(page) { + return page.evaluate(async () => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + return campaignModule.getCampaignState(); + }); +} + +async function interactWithActor(page, actorId) { + const teleported = await page.evaluate( + ({ key, targetId }) => + window.__HEROS_DEBUG__ + ?.scene(key) + ?.debugTeleportTo?.(targetId) ?? false, + { key: explorationSceneKey, targetId: actorId } + ); + assert.equal( + teleported, + true, + `Unable to approach relief actor ${actorId}.` + ); + await page.waitForTimeout(80); + await page.keyboard.press('e'); + await page.waitForFunction( + ({ targetId }) => + window.__HEROS_DEBUG__?.campVisitExploration?.()?.dialogue + ?.sourceNpcId === targetId, + { targetId: actorId }, + { timeout: 5000 } + ); +} + +async function completeActorObjective( + page, + actorId, + expectedCompletedObjectiveIds +) { + await interactWithActor(page, actorId); + await advanceDialogueUntilClosed(page); + await waitForObjectiveProgress( + page, + expectedCompletedObjectiveIds + ); + await page.waitForTimeout(360); +} + +async function waitForObjectiveProgress(page, expectedIds) { + await page.waitForFunction( + (requestedIds) => { + const actual = + window.__HEROS_DEBUG__?.campVisitExploration?.()?.visit + ?.completedObjectiveIds ?? []; + return JSON.stringify(actual) === JSON.stringify(requestedIds); + }, + expectedIds, + { timeout: 5000 } + ); +} + +async function advanceDialogueUntilClosed(page) { + for (let attempt = 0; attempt < 16; attempt += 1) { + const state = await readExploration(page); + if (!state?.dialogue?.active) { + return; + } + await page.keyboard.press('e'); + await page.waitForTimeout(150); + } + throw new Error( + `Dialogue did not close: ${JSON.stringify( + await readExploration(page) + )}` + ); +} + +async function advanceDialogueUntilChoice(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const state = await readExploration(page); + if (state?.choice?.open) { + return; + } + assert.equal( + state?.dialogue?.active, + true, + `Jian Yong dialogue closed without choices: ${JSON.stringify( + state + )}` + ); + await page.keyboard.press('e'); + await page.waitForTimeout(150); + } + throw new Error( + `Relief choice did not open: ${JSON.stringify( + await readExploration(page) + )}` + ); +} + +async function clickScenePoint(page, sceneKey, sceneX, sceneY) { + const point = await page.evaluate( + ({ key, x, y }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + if (!scene || !bounds) { + return null; + } + return { + x: + bounds.left + + (x * bounds.width) / scene.scale.width, + y: + bounds.top + + (y * bounds.height) / scene.scale.height + }; + }, + { key: sceneKey, x: sceneX, y: sceneY } + ); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y), + `Unable to map ${sceneKey} point ${JSON.stringify({ + x: sceneX, + y: sceneY + })}.` + ); + await page.mouse.click(point.x, point.y); +} + +async function clickSceneBounds(page, sceneKey, bounds) { + assert(bounds, `${sceneKey} clickable bounds are missing.`); + await clickScenePoint( + page, + sceneKey, + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); +} + +function assertActorsFixed(exploration, label) { + assert.equal( + exploration.actors.length, + Object.keys(expectedActors).length, + `${label}: unexpected actor count.` + ); + Object.entries(expectedActors).forEach(([actorId, expected]) => { + const actor = exploration.actors.find( + (candidate) => candidate.id === actorId + ); + assert(actor, `${label}: missing actor ${actorId}.`); + assert.deepEqual( + { + x: actor.x, + y: actor.y, + initialX: actor.initialX, + initialY: actor.initialY, + moved: actor.moved, + textureKey: actor.textureKey + }, + { + x: expected.x, + y: expected.y, + initialX: expected.x, + initialY: expected.y, + moved: false, + textureKey: expected.textureKey + }, + `${label}: ${actorId} must stay at its authored position.` + ); + }); +} + +function assertRewardAppliedOnce( + before, + after, + bondBefore, + fixture +) { + assert.equal( + after.completedCampVisits.filter( + (candidate) => candidate === visitId + ).length, + 1 + ); + assert.equal( + after.firstBattleReport.completedCampVisits.filter( + (candidate) => candidate === visitId + ).length, + 1 + ); + assert.equal( + after.campVisitChoiceIds[visitId], + fixture.reliefChoiceId + ); + assert.equal( + after.inventory[fixture.rewardItemLabel] ?? 0, + (before.inventory[fixture.rewardItemLabel] ?? 0) + 1 + ); + objectiveProgressIds.forEach((progressId) => { + assert.equal( + after.completedCampVisits.filter( + (candidate) => candidate === progressId + ).length, + 1 + ); + }); + const bondAfter = campaignBond(after, fixture.bondId); + assert(bondAfter, `Missing rewarded bond ${fixture.bondId}.`); + assert.equal( + bondProgress(bondAfter) - bondProgress(bondBefore), + 10 + ); +} + +function relevantProgress(campaign, fixture) { + const bond = campaignBond(campaign, fixture.bondId); + return { + visitFlags: campaign.completedCampVisits.filter( + (candidate) => + candidate === visitId || + objectiveProgressIds.includes(candidate) + ), + reportVisitFlags: + campaign.firstBattleReport?.completedCampVisits?.filter( + (candidate) => + candidate === visitId || + objectiveProgressIds.includes(candidate) + ) ?? [], + choiceId: campaign.campVisitChoiceIds[visitId] ?? null, + rewardAmount: + campaign.inventory[fixture.rewardItemLabel] ?? 0, + bond: bond + ? { + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null + }; +} + +function campaignBond(campaign, bondId) { + return campaign?.bonds?.find((bond) => bond.id === bondId); +} + +function bondProgress(bond) { + return bond.level * 100 + bond.exp; +} + +async function capture(page, path) { + await page.waitForTimeout(180); + await page.screenshot({ path, fullPage: false }); +} + +function cliOption(name) { + const prefix = `--${name}=`; + return process.argv + .slice(2) + .find((argument) => argument.startsWith(prefix)) + ?.slice(prefix.length); +} diff --git a/scripts/verify-second-battle-relief-exploration.mjs b/scripts/verify-second-battle-relief-exploration.mjs new file mode 100644 index 0000000..a54c547 --- /dev/null +++ b/scripts/verify-second-battle-relief-exploration.mjs @@ -0,0 +1,657 @@ +import { createServer } from 'vite'; + +const storage = new Map(); +let rejectStorageWrites = false; +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if (rejectStorageWrites) { + throw new Error('Simulated campaign storage write failure.'); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const dataModule = await server.ssrLoadModule( + '/src/game/data/secondBattleReliefExploration.ts' + ); + const memoryModule = await server.ssrLoadModule( + '/src/game/data/secondBattleReliefMemory.ts' + ); + const actionModule = await server.ssrLoadModule( + '/src/game/state/secondBattleReliefActions.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + const { usableCatalog } = await server.ssrLoadModule( + '/src/game/data/battleUsables.ts' + ); + const explorationAssets = await server.ssrLoadModule( + '/src/game/data/explorationCharacterAssets.ts' + ); + const portraitAssets = await server.ssrLoadModule( + '/src/game/data/portraitAssets.ts' + ); + const firstScoutModule = await server.ssrLoadModule( + '/src/game/data/firstPursuitScoutMemory.ts' + ); + + verifyCanonicalDefinition( + dataModule, + battleScenarios, + usableCatalog, + explorationAssets, + portraitAssets + ); + verifyBattleResultAndScoutReview( + dataModule, + memoryModule, + firstScoutModule + ); + verifyTargetBattleMemories(dataModule, memoryModule); + verifyGuardedPersistentProgress( + dataModule, + memoryModule, + actionModule, + campaignModule, + battleScenarios + ); + + console.log( + 'Second-battle relief exploration verification passed (canonical route data, ordered resumable objectives, first-scout result review, guarded rewards, and third-battle memory isolation).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalDefinition( + dataModule, + battleScenarios, + usableCatalog, + explorationAssets, + portraitAssets +) { + const definition = + dataModule.secondBattleReliefExplorationDefinition; + const source = + battleScenarios[dataModule.secondBattleReliefSourceBattleId]; + const target = + battleScenarios[dataModule.secondBattleReliefTargetBattleId]; + assert( + definition.id === dataModule.secondBattleReliefVisitId && + definition.sourceBattleId === source.id && + definition.targetBattleId === target.id, + 'The relief route must stay scoped from the second victory to the third battle.' + ); + assert( + source.objectives.some( + (objective) => + objective.id === + dataModule.secondBattleReliefSourceObjectiveIds.village + ) && + source.objectives.some( + (objective) => + objective.id === + dataModule.secondBattleReliefSourceObjectiveIds.quick + ), + 'The relief memory must reference the authored village and quick-victory objectives.' + ); + + const actorIds = new Set(); + definition.actors.forEach((actor) => { + assert(!actorIds.has(actor.id), `Duplicate relief actor id ${actor.id}.`); + actorIds.add(actor.id); + assert( + pointInside(actor, definition.movementBounds), + `Actor ${actor.id} must stay inside the FHD exploration movement bounds.` + ); + assert( + explorationAssets.hasExplorationCharacterAsset(actor.textureKey), + `Actor ${actor.id} must use an existing exploration character sheet.` + ); + assert( + typeof actor.portraitKey === 'string' && + portraitAssets.portraitAssetEntriesForKey(actor.portraitKey) + .length > 0, + `Actor ${actor.id} must use an existing face portrait in dialogue.` + ); + assert( + actor.dialogue.length >= 2 && actor.repeatDialogue.length >= 1, + `Actor ${actor.id} must have first-time and repeat dialogue.` + ); + }); + assert( + pointInside(definition.player, definition.movementBounds) && + pointInside(definition.exit, definition.movementBounds), + 'The player start and camp exit must stay inside the movement bounds.' + ); + + const objectiveIds = new Set(); + definition.objectives.forEach((objective, index) => { + assert( + !objectiveIds.has(objective.id), + `Duplicate relief objective id ${objective.id}.` + ); + assert( + actorIds.has(objective.targetActorId), + `Objective ${objective.id} must point to a canonical actor.` + ); + objective.prerequisiteObjectiveIds.forEach((prerequisiteId) => { + assert( + objectiveIds.has(prerequisiteId), + `Objective ${objective.id} prerequisite ${prerequisiteId} must appear earlier in the route.` + ); + }); + assert( + objective.id === + dataModule.secondBattleReliefObjectiveIds[index], + `Objective ${objective.id} must retain the authored route order.` + ); + objectiveIds.add(objective.id); + }); + assert( + JSON.stringify( + dataModule.secondBattleReliefPrerequisiteObjectiveIds + ) === + JSON.stringify( + dataModule.secondBattleReliefObjectiveIds.slice(0, -1) + ), + 'Every field interaction before the final Jian Yong choice must be a persisted prerequisite.' + ); + + assert( + JSON.stringify(definition.choices.map(({ id }) => id)) === + JSON.stringify(dataModule.secondBattleReliefChoiceIds), + 'The two relief choices must remain canonical and ordered.' + ); + definition.choices.forEach((choice) => { + const effect = choice.thirdBattleEffect; + assert( + choice.bondExp > 0 && + choice.itemRewards.length === 1 && + /^.+\s+\d+$/.test(choice.itemRewards[0]), + `Choice ${choice.id} must provide valid bond and item feedback.` + ); + assert( + target.objectives.some( + (objective) => objective.id === effect.focusObjectiveId + ), + `Choice ${choice.id} must focus an objective in the third battle.` + ); + assert( + target.units.some( + (unit) => + unit.id === effect.recommendedUnitId && + unit.faction === 'ally' + ), + `Choice ${choice.id} must recommend an allied third-battle unit.` + ); + if (effect.kind === 'village-supply-line') { + assert( + usableCatalog[effect.carriedUsableId]?.command === 'item', + 'The village supply payoff must reference a real battle item.' + ); + } else { + assert( + target.units.some( + (unit) => + unit.id === effect.trackedEnemyUnitId && + unit.faction === 'enemy' + ), + 'The ferry intelligence payoff must track the authored third-battle leader.' + ); + } + }); +} + +function verifyBattleResultAndScoutReview( + dataModule, + memoryModule, + firstScoutModule +) { + const cases = [ + { + choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0], + villageAchieved: true, + quickAchieved: true, + expectedFocus: 'river', + expectedOutcome: 'confirmed' + }, + { + choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0], + villageAchieved: true, + quickAchieved: false, + expectedFocus: 'river', + expectedOutcome: 'missed' + }, + { + choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1], + villageAchieved: true, + quickAchieved: false, + expectedFocus: 'village', + expectedOutcome: 'confirmed' + }, + { + choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1], + villageAchieved: false, + quickAchieved: true, + expectedFocus: 'village', + expectedOutcome: 'missed' + } + ]; + + cases.forEach((testCase) => { + const campaign = literalCampaign(dataModule, { + villageAchieved: testCase.villageAchieved, + quickAchieved: testCase.quickAchieved, + firstScoutVisitId: firstScoutModule.firstPursuitScoutVisitId, + firstScoutChoiceId: testCase.choiceId + }); + const context = + memoryModule.resolveSecondBattleReliefContext(campaign); + assert( + context?.scoutReview.choiceId === testCase.choiceId && + context.scoutReview.focus === testCase.expectedFocus && + context.scoutReview.outcome === testCase.expectedOutcome, + `The first scout choice must be reviewed against its matching second-battle result: ${JSON.stringify({ + testCase, + context + })}` + ); + }); + + const unrecorded = + memoryModule.resolveSecondBattleReliefContext( + literalCampaign(dataModule, { + villageAchieved: false, + quickAchieved: false + }) + ); + assert( + unrecorded?.scoutReview.outcome === 'unrecorded', + 'Legacy saves without a first-scout marker must receive a safe unrecorded fallback.' + ); +} + +function verifyTargetBattleMemories(dataModule, memoryModule) { + dataModule.secondBattleReliefChoiceIds.forEach((choiceId) => { + const campaign = literalCampaign(dataModule, { + villageAchieved: choiceId === + 'restore-northern-village', + quickAchieved: choiceId === 'trace-ferry-messenger', + reliefChoiceId: choiceId + }); + const memory = memoryModule.resolveSecondBattleReliefMemory({ + campaign, + battleId: dataModule.secondBattleReliefTargetBattleId + }); + assert( + memory?.choiceId === choiceId && + memory.effect.readiness === 'reinforced' && + memory.effect.sourceObjectiveAchieved === true && + memory.openingLines.length === 3 && + memory.openingLines[0] === memory.campSummaryLine, + `Choice ${choiceId} must resolve a reinforced third-battle memory.` + ); + + const recoveredCampaign = literalCampaign(dataModule, { + villageAchieved: false, + quickAchieved: false, + reliefChoiceId: choiceId + }); + const recovered = memoryModule.resolveSecondBattleReliefMemory({ + campaign: recoveredCampaign, + battleId: dataModule.secondBattleReliefTargetBattleId + }); + assert( + recovered?.effect.readiness === 'recovered' && + recovered.effect.sourceObjectiveAchieved === false, + `Choice ${choiceId} must turn a missed source objective into a clearly labelled recovery, not a false success.` + ); + assert( + memoryModule.resolveSecondBattleReliefMemory({ + campaign, + battleId: dataModule.secondBattleReliefSourceBattleId + }) === undefined, + `Choice ${choiceId} memory must not leak back into the source battle.` + ); + }); + + const forged = literalCampaign(dataModule, { + villageAchieved: true, + quickAchieved: true, + reliefChoiceId: 'forged-choice' + }); + assert( + memoryModule.resolveSecondBattleReliefMemory({ + campaign: forged, + battleId: dataModule.secondBattleReliefTargetBattleId + }) === undefined, + 'A forged relief choice must never produce a third-battle effect.' + ); +} + +function verifyGuardedPersistentProgress( + dataModule, + memoryModule, + actionModule, + campaignModule, + battleScenarios +) { + storage.clear(); + campaignModule.resetCampaignState(); + assert( + actionModule.recordSecondBattleReliefObjective( + dataModule.secondBattleReliefObjectiveIds[0] + ).reason === 'invalid-campaign', + 'Relief progress must reject completion before the second victory camp.' + ); + + prepareSecondVictory( + campaignModule, + battleScenarios, + dataModule, + { villageAchieved: true, quickAchieved: false } + ); + assert( + actionModule.recordSecondBattleReliefObjective( + dataModule.secondBattleReliefObjectiveIds[1] + ).reason === 'prerequisites-incomplete', + 'The ferry testimony must not be recordable before the village check.' + ); + assert( + actionModule.completeSecondBattleReliefExploration( + dataModule.secondBattleReliefChoiceIds[0] + ).reason === 'prerequisites-incomplete', + 'The final choice must remain locked until every field objective is persisted.' + ); + + dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach( + (objectiveId, index) => { + const result = + actionModule.recordSecondBattleReliefObjective(objectiveId); + assert( + result.ok === true && + result.completedObjectiveIds.length === index + 1, + `Objective ${objectiveId} must persist in authored order.` + ); + } + ); + const resumedProgress = actionModule.secondBattleReliefProgress(); + assert( + resumedProgress.choiceReady === true && + JSON.stringify(resumedProgress.completedObjectiveIds) === + JSON.stringify( + dataModule.secondBattleReliefPrerequisiteObjectiveIds + ), + 'Reloadable campaign markers must reconstruct all completed field objectives.' + ); + + const choice = + dataModule.secondBattleReliefExplorationDefinition.choices[0]; + const inventoryBefore = + campaignModule.getCampaignState().inventory; + const completed = + actionModule.completeSecondBattleReliefExploration(choice.id); + assert( + completed.ok === true && + completed.memory.choiceId === choice.id && + completed.memory.targetBattleId === + dataModule.secondBattleReliefTargetBattleId && + completed.campaign.completedCampVisits.includes( + dataModule.secondBattleReliefVisitId + ) && + completed.campaign.campVisitChoiceIds[ + dataModule.secondBattleReliefVisitId + ] === choice.id, + 'A valid final choice must atomically save the route, reward, and third-battle memory.' + ); + assertRewardApplied(choice, inventoryBefore, completed.campaign); + const completedProgress = actionModule.secondBattleReliefProgress(); + assert( + completedProgress.completed === true && + completedProgress.nextObjectiveId === null && + completedProgress.choiceReady === false, + 'A completed route must not keep advertising a stale final objective or another choice.' + ); + assert( + actionModule.completeSecondBattleReliefExploration(choice.id) + .reason === 'already-completed', + 'The completed route must reject duplicate rewards.' + ); + + prepareSecondVictory( + campaignModule, + battleScenarios, + dataModule, + { villageAchieved: false, quickAchieved: true } + ); + dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach( + (objectiveId) => { + const result = + actionModule.recordSecondBattleReliefObjective(objectiveId); + assert(result.ok === true, `Expected progress for ${objectiveId}.`); + } + ); + const beforeFailedSave = progressSnapshot( + campaignModule.getCampaignState(), + dataModule + ); + let failedSave; + rejectStorageWrites = true; + try { + failedSave = actionModule.completeSecondBattleReliefExploration( + dataModule.secondBattleReliefChoiceIds[1] + ); + } finally { + rejectStorageWrites = false; + } + const afterFailedSave = progressSnapshot( + campaignModule.getCampaignState(), + dataModule + ); + assert( + failedSave?.ok === false && + failedSave.reason === 'save-unavailable' && + JSON.stringify(afterFailedSave) === + JSON.stringify(beforeFailedSave), + `A failed save must restore the route, rewards, and memory markers: ${JSON.stringify({ + failedSave, + beforeFailedSave, + afterFailedSave + })}` + ); + + const finalCampaign = campaignModule.getCampaignState(); + assert( + memoryModule.resolveSecondBattleReliefMemory({ + campaign: finalCampaign, + battleId: dataModule.secondBattleReliefTargetBattleId + }) === undefined, + 'A failed final write must not leave a ghost third-battle memory.' + ); +} + +function prepareSecondVictory( + campaignModule, + battleScenarios, + dataModule, + { villageAchieved, quickAchieved } +) { + storage.clear(); + campaignModule.resetCampaignState(); + const scenario = + battleScenarios[dataModule.secondBattleReliefSourceBattleId]; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: quickAchieved ? 18 : 27, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => { + const achieved = objective.id === + dataModule.secondBattleReliefSourceObjectiveIds.village + ? villageAchieved + : objective.id === + dataModule.secondBattleReliefSourceObjectiveIds.quick + ? quickAchieved + : true; + return { + id: objective.id, + label: objective.label, + achieved, + status: achieved ? 'done' : 'failed', + detail: achieved ? '달성' : '미달', + rewardGold: objective.rewardGold + }; + }), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + const campaign = campaignModule.getCampaignState(); + assert( + campaign.step === 'second-camp' && + campaign.latestBattleId === scenario.id, + 'The canonical second victory must route into second-camp.' + ); + return campaign; +} + +function literalCampaign( + dataModule, + { + villageAchieved, + quickAchieved, + reliefChoiceId, + firstScoutVisitId, + firstScoutChoiceId + } +) { + const completedCampVisits = []; + const campVisitChoiceIds = {}; + if (reliefChoiceId) { + completedCampVisits.push(dataModule.secondBattleReliefVisitId); + campVisitChoiceIds[dataModule.secondBattleReliefVisitId] = + reliefChoiceId; + } + if (firstScoutVisitId && firstScoutChoiceId) { + completedCampVisits.push(firstScoutVisitId); + campVisitChoiceIds[firstScoutVisitId] = firstScoutChoiceId; + } + return { + battleHistory: { + [dataModule.secondBattleReliefSourceBattleId]: { + battleId: dataModule.secondBattleReliefSourceBattleId, + outcome: 'victory', + turnNumber: quickAchieved ? 18 : 27, + objectives: [ + { + id: dataModule.secondBattleReliefSourceObjectiveIds.village, + achieved: villageAchieved, + status: villageAchieved ? 'done' : 'failed' + }, + { + id: dataModule.secondBattleReliefSourceObjectiveIds.quick, + achieved: quickAchieved, + status: quickAchieved ? 'done' : 'failed' + } + ] + } + }, + completedCampVisits, + campVisitChoiceIds + }; +} + +function assertRewardApplied(choice, inventoryBefore, campaign) { + choice.itemRewards.forEach((reward) => { + const match = /^(.+?)\s+(\d+)$/.exec(reward); + assert(match, `Invalid reward label ${reward}.`); + const [, label, amountText] = match; + const amount = Number.parseInt(amountText, 10); + assert( + (campaign.inventory[label] ?? 0) === + (inventoryBefore[label] ?? 0) + amount, + `Expected ${reward} to be granted exactly once.` + ); + }); +} + +function progressSnapshot(campaign, dataModule) { + const relevantIds = campaign.completedCampVisits.filter( + (visitId) => + visitId === dataModule.secondBattleReliefVisitId || + visitId.startsWith( + `${dataModule.secondBattleReliefVisitId}:objective:` + ) + ); + return { + completedCampVisits: relevantIds, + reportCompletedCampVisits: + campaign.firstBattleReport?.completedCampVisits.filter( + (visitId) => relevantIds.includes(visitId) + ) ?? [], + choiceId: + campaign.campVisitChoiceIds[ + dataModule.secondBattleReliefVisitId + ] ?? null, + inventory: { ...campaign.inventory }, + bonds: campaign.bonds.map( + ({ id, level, exp, battleExp }) => ({ + id, + level, + exp, + battleExp + }) + ) + }; +} + +function pointInside(point, bounds) { + return ( + point.x >= bounds.x && + point.y >= bounds.y && + point.x <= bounds.x + bounds.width && + point.y <= bounds.y + bounds.height + ); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 9ab1b0d..1bbcc60 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -26,6 +26,7 @@ const checks = [ 'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs', 'scripts/verify-first-pursuit-scout-memory.mjs', + 'scripts/verify-second-battle-relief-exploration.mjs', 'scripts/verify-prologue-exploration-asset-data.mjs', 'scripts/verify-exploration-character-asset-data.mjs', 'scripts/verify-prologue-dialogue-portrait-data.mjs', diff --git a/src/assets/audio/ambience/riverside-village-day-ambience.mp3 b/src/assets/audio/ambience/riverside-village-day-ambience.mp3 new file mode 100644 index 0000000..3fe39d3 Binary files /dev/null and b/src/assets/audio/ambience/riverside-village-day-ambience.mp3 differ diff --git a/src/assets/images/exploration/second-pursuit-village-ferry.webp b/src/assets/images/exploration/second-pursuit-village-ferry.webp new file mode 100644 index 0000000..eaa7343 Binary files /dev/null and b/src/assets/images/exploration/second-pursuit-village-ferry.webp differ diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index e9dc9f0..1a6fc20 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -59,6 +59,7 @@ import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.m import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3'; import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3'; import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3'; +import riversideVillageDayAmbienceUrl from '../../assets/audio/ambience/riverside-village-day-ambience.mp3'; export const musicTracks = { 'title-theme': titleThemeUrl, @@ -77,6 +78,7 @@ export const ambienceTracks = { 'battle-fire': battleFireAmbienceUrl, 'campfire-ambience': campfireAmbienceUrl, 'river-night-ambience': riverNightAmbienceUrl, + 'riverside-village-day-ambience': riversideVillageDayAmbienceUrl, 'mountain-wind-ambience': mountainWindAmbienceUrl, 'nanzhong-night-ambience': nanzhongNightAmbienceUrl } as const; @@ -93,6 +95,7 @@ export const ambienceTrackGainCompensation = { 'battle-fire': 0.72, 'campfire-ambience': 1, 'river-night-ambience': 1, + 'riverside-village-day-ambience': 1, 'mountain-wind-ambience': 0.88, 'nanzhong-night-ambience': 1 } as const satisfies Record; @@ -120,6 +123,7 @@ export const ambienceTrackIntegratedLoudnessLufs = { 'battle-fire': -26.11, 'campfire-ambience': -24.58, 'river-night-ambience': -25.2, + 'riverside-village-day-ambience': -24.03, 'mountain-wind-ambience': -24.9, 'nanzhong-night-ambience': -24.43 } as const satisfies Record; diff --git a/src/game/data/secondBattleReliefExploration.ts b/src/game/data/secondBattleReliefExploration.ts new file mode 100644 index 0000000..0258fdd --- /dev/null +++ b/src/game/data/secondBattleReliefExploration.ts @@ -0,0 +1,436 @@ +import type { BattleScenarioId } from './battles'; +import type { + ExplorationCharacterDirection, + ExplorationCharacterTextureKey +} from './explorationCharacterAssets'; + +export const secondBattleReliefVisitId = + 'second-pursuit-aftermath-relief'; +export const secondBattleReliefSourceBattleId = + 'second-battle-yellow-turban-pursuit' satisfies BattleScenarioId; +export const secondBattleReliefTargetBattleId = + 'third-battle-guangzong-road' satisfies BattleScenarioId; + +export const secondBattleReliefChoiceIds = [ + 'restore-northern-village', + 'trace-ferry-messenger' +] as const; +export type SecondBattleReliefChoiceId = + (typeof secondBattleReliefChoiceIds)[number]; + +export const secondBattleReliefObjectiveIds = [ + 'reassure-northern-village', + 'question-ferry-witness', + 'inspect-messenger-trail', + 'set-guangzong-priority' +] as const; +export type SecondBattleReliefObjectiveId = + (typeof secondBattleReliefObjectiveIds)[number]; + +export const secondBattleReliefPrerequisiteObjectiveIds = + secondBattleReliefObjectiveIds.slice(0, -1) as readonly SecondBattleReliefObjectiveId[]; + +export const secondBattleReliefSourceObjectiveIds = { + village: 'village', + quick: 'quick' +} as const; + +export type SecondBattleReliefActorId = + | 'guan-yu-village' + | 'north-ferry-boatman' + | 'zhang-fei-ferry' + | 'jian-yong-records'; + +export type SecondBattleReliefDialogueLine = { + readonly speaker: string; + readonly text: string; + readonly portraitKey?: string; +}; + +export type SecondBattleReliefActor = { + readonly id: SecondBattleReliefActorId; + readonly name: string; + readonly role: string; + readonly textureKey: ExplorationCharacterTextureKey; + readonly portraitKey?: string; + readonly x: number; + readonly y: number; + readonly direction: ExplorationCharacterDirection; + readonly dialogue: readonly SecondBattleReliefDialogueLine[]; + readonly repeatDialogue: readonly SecondBattleReliefDialogueLine[]; +}; + +export type SecondBattleReliefObjective = { + readonly id: SecondBattleReliefObjectiveId; + readonly label: string; + readonly detail: string; + readonly targetActorId: SecondBattleReliefActorId; + readonly prerequisiteObjectiveIds: readonly SecondBattleReliefObjectiveId[]; + readonly completionLine: string; +}; + +type SecondBattleReliefEffectBase = { + readonly id: + | 'northern-village-supply-line' + | 'guangzong-messenger-route'; + readonly targetBattleId: typeof secondBattleReliefTargetBattleId; + readonly label: string; + readonly summary: string; + readonly openingLines: readonly [string, string]; +}; + +export type SecondBattleReliefThirdBattleEffect = + | (SecondBattleReliefEffectBase & { + readonly kind: 'village-supply-line'; + readonly focusObjectiveId: 'liu-bei'; + readonly recommendedUnitId: 'liu-bei'; + readonly recommendedRole: 'support'; + readonly carriedUsableId: 'bean'; + readonly carriedUsableCount: 1; + }) + | (SecondBattleReliefEffectBase & { + readonly kind: 'ferry-messenger-intel'; + readonly focusObjectiveId: 'leader'; + readonly recommendedUnitId: 'zhang-fei'; + readonly recommendedRole: 'flank'; + readonly trackedEnemyUnitId: 'guangzong-leader-ma-yuan'; + readonly revealTrackedRoute: true; + }); + +export type SecondBattleReliefChoice = { + readonly id: SecondBattleReliefChoiceId; + readonly label: string; + readonly response: string; + readonly bondId: 'liu-bei__guan-yu' | 'liu-bei__jian-yong'; + readonly bondExp: number; + readonly itemRewards: readonly [string]; + readonly thirdBattleEffect: SecondBattleReliefThirdBattleEffect; +}; + +export type SecondBattleReliefExplorationDefinition = { + readonly id: typeof secondBattleReliefVisitId; + readonly title: string; + readonly location: string; + readonly sourceBattleId: typeof secondBattleReliefSourceBattleId; + readonly targetBattleId: typeof secondBattleReliefTargetBattleId; + readonly heading: string; + readonly subtitle: string; + readonly description: string; + readonly introLines: readonly SecondBattleReliefDialogueLine[]; + readonly movementBounds: { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + }; + readonly player: { + readonly textureKey: 'exploration-liu-bei'; + readonly x: number; + readonly y: number; + readonly direction: ExplorationCharacterDirection; + }; + readonly exit: { + readonly id: 'camp-exit'; + readonly name: string; + readonly x: number; + readonly y: number; + }; + readonly actors: readonly SecondBattleReliefActor[]; + readonly objectives: readonly SecondBattleReliefObjective[]; + readonly choices: readonly SecondBattleReliefChoice[]; +}; + +export const secondBattleReliefExplorationDefinition = { + id: secondBattleReliefVisitId, + title: '북쪽 마을과 나루 수습', + location: '탁현 북쪽 마을 · 강가 나루', + sourceBattleId: secondBattleReliefSourceBattleId, + targetBattleId: secondBattleReliefTargetBattleId, + heading: '황건 잔당 추격전 뒤', + subtitle: '마을의 상처를 살피고 나루에 남은 광종 전령의 흔적을 확인합니다.', + description: + '전투에서 확보한 북쪽 마을은 아직 수습이 끝나지 않았습니다. 주민의 안전과 나루의 증언을 직접 확인한 뒤 광종 구원로에서 무엇을 먼저 지킬지 정합니다.', + introLines: [ + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '싸움은 끝났으나 백성이 집으로 돌아가고 나루가 다시 열려야 비로소 이 길을 지켰다 할 수 있소.' + }, + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: '마을의 피해와 사공의 증언을 함께 적겠습니다. 그 기록이 광종으로 향할 다음 군령이 될 것입니다.' + } + ], + movementBounds: { x: 42, y: 108, width: 1404, height: 814 }, + player: { + textureKey: 'exploration-liu-bei', + x: 950, + y: 840, + direction: 'north' + }, + exit: { + id: 'camp-exit', + name: '수습 현장을 떠나 군영으로 돌아가기', + x: 790, + y: 914 + }, + actors: [ + { + id: 'guan-yu-village', + name: '관우', + role: '북쪽 마을 부상자 수습', + textureKey: 'exploration-guan-yu', + portraitKey: 'guan-yu-yellow-turban', + x: 1050, + y: 455, + direction: 'west', + dialogue: [ + { + speaker: '관우', + portraitKey: 'guan-yu-yellow-turban', + text: '불은 껐지만 빼앗겼던 곡식과 다친 이들이 남았습니다. 전열을 거두기 전에 마을이 오늘 밤을 버틸 수 있게 해야 합니다.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '군량을 셈하기 전에 백성의 솥부터 살핍시다. 여기서 얻은 승리가 다시 원망이 되어서는 안 되오.' + } + ], + repeatDialogue: [ + { + speaker: '관우', + portraitKey: 'guan-yu-yellow-turban', + text: '마을 사람들의 귀환로는 열어 두었습니다. 이제 나루에서 놓친 흔적을 확인하십시오.' + } + ] + }, + { + id: 'north-ferry-boatman', + name: '나루 사공', + role: '전령 이동 목격자', + textureKey: 'exploration-zhuo-villager', + portraitKey: 'zhuo-villager-yellow-turban', + x: 360, + y: 470, + direction: 'east', + dialogue: [ + { + speaker: '나루 사공', + text: '두령이 쓰러지기 전, 누런 깃발을 감춘 기병 하나가 말을 배에 싣고 건넜습니다. 광종 쪽 물길을 잘 아는 자였습니다.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '사람을 더 쫓게 하지는 않겠소. 어느 배를 썼고 어디에 말발굽이 남았는지만 알려 주시오.' + } + ], + repeatDialogue: [ + { + speaker: '나루 사공', + text: '동쪽 말뚝에 묶였던 작은 배였습니다. 장비 장군이 그 주변의 자국을 살피고 있습니다.' + } + ] + }, + { + id: 'zhang-fei-ferry', + name: '장비', + role: '나루와 말발굽 흔적 확인', + textureKey: 'exploration-zhang-fei', + portraitKey: 'zhang-fei-yellow-turban', + x: 540, + y: 675, + direction: 'east', + dialogue: [ + { + speaker: '장비', + portraitKey: 'zhang-fei-yellow-turban', + text: '사공 말이 맞소. 배 밑의 진흙과 말발굽이 광종 길로 이어집니다. 지금 뒤쫓으면 흔적을 더 잡을 수 있소.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '흔적은 간옹에게 남기고 병사부터 모으시오. 빠른 추격과 마을 수습 중 무엇을 앞세울지는 함께 정하겠소.' + } + ], + repeatDialogue: [ + { + speaker: '장비', + portraitKey: 'zhang-fei-yellow-turban', + text: '나루의 흔적은 표시해 두었소. 간옹에게 가면 바로 다음 명을 정할 수 있소.' + } + ] + }, + { + id: 'jian-yong-records', + name: '간옹', + role: '수습 기록과 광종 진군안', + textureKey: 'exploration-jian-yong', + portraitKey: 'jian-yong-campaign', + x: 790, + y: 690, + direction: 'north', + dialogue: [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: '마을 보급선을 세우면 광종 길의 후방이 단단해집니다. 나루 흔적을 좇으면 전령 마원의 퇴로를 먼저 읽을 수 있습니다.' + }, + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: '둘 다 버릴 수는 없으나, 이번 출정에서 먼저 세울 기준은 하나여야 하오. 기록에 남겨 군 전체가 따르게 합시다.' + } + ], + repeatDialogue: [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: '정한 방침은 광종 구원로 출진 장부에 옮겨 두었습니다.' + } + ] + } + ], + objectives: [ + { + id: 'reassure-northern-village', + label: '북쪽 마을의 부상자와 귀환로 확인', + detail: '관우에게 다가가 전투 뒤에도 남은 마을의 피해를 확인합니다.', + targetActorId: 'guan-yu-village', + prerequisiteObjectiveIds: [], + completionLine: '북쪽 마을 주민이 돌아올 길과 오늘 밤의 식량을 확인했습니다.' + }, + { + id: 'question-ferry-witness', + label: '나루 사공에게 전령의 이동 경로 질문', + detail: '마을 수습을 확인한 뒤 사공에게 빠져나간 기병과 배의 흔적을 묻습니다.', + targetActorId: 'north-ferry-boatman', + prerequisiteObjectiveIds: ['reassure-northern-village'], + completionLine: '광종 방향으로 빠져나간 황건 전령과 사용한 배를 확인했습니다.' + }, + { + id: 'inspect-messenger-trail', + label: '장비와 나루의 말발굽 흔적 확인', + detail: '사공의 증언을 토대로 장비가 표시한 물가의 흔적을 직접 확인합니다.', + targetActorId: 'zhang-fei-ferry', + prerequisiteObjectiveIds: [ + 'reassure-northern-village', + 'question-ferry-witness' + ], + completionLine: '배 밑 진흙과 말발굽이 광종 구원로로 이어진다는 사실을 확인했습니다.' + }, + { + id: 'set-guangzong-priority', + label: '간옹과 광종 구원로의 우선 방침 결정', + detail: '모은 기록을 간옹에게 전하고 마을 보급과 전령 추적 중 다음 출정의 우선 기준을 고릅니다.', + targetActorId: 'jian-yong-records', + prerequisiteObjectiveIds: [ + 'reassure-northern-village', + 'question-ferry-witness', + 'inspect-messenger-trail' + ], + completionLine: '현장 수습과 광종 출정 방침을 하나의 장부로 남겼습니다.' + } + ], + choices: [ + { + id: 'restore-northern-village', + label: '북쪽 마을 보급선을 먼저 세운다', + response: + '관우가 마을의 귀환로를 지키고 간옹은 남은 곡식과 부상자 수를 장부에 적었습니다. 광종으로 향하는 군은 이 마을을 안전한 후방 보급점으로 삼습니다.', + bondId: 'liu-bei__guan-yu', + bondExp: 10, + itemRewards: ['콩 1'], + thirdBattleEffect: { + id: 'northern-village-supply-line', + kind: 'village-supply-line', + targetBattleId: secondBattleReliefTargetBattleId, + label: '북쪽 마을 보급선', + summary: '유비의 후방 지휘와 회복 여지를 우선하는 광종 구원로 준비', + focusObjectiveId: 'liu-bei', + recommendedUnitId: 'liu-bei', + recommendedRole: 'support', + carriedUsableId: 'bean', + carriedUsableCount: 1, + openingLines: [ + '현장 수습 · 북쪽 마을 보급선이 광종 구원로의 후방과 이어집니다.', + '유비를 후방 지원 위치에 두고, 마을에서 챙긴 콩을 출전 장비에 배정해 전열의 회복 여지를 남기십시오.' + ] + } + }, + { + id: 'trace-ferry-messenger', + label: '나루의 전령 흔적을 먼저 좇는다', + response: + '장비가 말발굽을 표시하고 간옹은 사공의 증언을 지도에 옮겼습니다. 광종 구원로에서는 전령 마원의 퇴로가 가장 먼저 확인해야 할 길이 됩니다.', + bondId: 'liu-bei__jian-yong', + bondExp: 10, + itemRewards: ['상처약 1'], + thirdBattleEffect: { + id: 'guangzong-messenger-route', + kind: 'ferry-messenger-intel', + targetBattleId: secondBattleReliefTargetBattleId, + label: '나루 전령로', + summary: '전령 마원의 광종 방면 퇴로와 첫 이동을 우선 확인하는 준비', + focusObjectiveId: 'leader', + recommendedUnitId: 'zhang-fei', + recommendedRole: 'flank', + trackedEnemyUnitId: 'guangzong-leader-ma-yuan', + revealTrackedRoute: true, + openingLines: [ + '현장 수습 · 나루의 말발굽과 배 흔적이 전령 마원의 광종 퇴로와 이어집니다.', + '장비를 좁은 길의 측면에 두고, 전령이 요새 뒤로 빠지기 전에 표시된 퇴로를 먼저 압박하십시오.' + ] + } + } + ] +} as const satisfies SecondBattleReliefExplorationDefinition; + +export function getSecondBattleReliefChoice( + choiceId: string +): SecondBattleReliefChoice | undefined { + return secondBattleReliefExplorationDefinition.choices.find( + (choice) => choice.id === choiceId + ); +} + +export function getSecondBattleReliefObjective( + objectiveId: string +): SecondBattleReliefObjective | undefined { + return secondBattleReliefExplorationDefinition.objectives.find( + (objective) => objective.id === objectiveId + ); +} + +export function isSecondBattleReliefChoiceId( + value: unknown +): value is SecondBattleReliefChoiceId { + return ( + typeof value === 'string' && + (secondBattleReliefChoiceIds as readonly string[]).includes(value) + ); +} + +export function completedSecondBattleReliefPrerequisites( + completedObjectiveIds: readonly string[] +) { + const completed = new Set(completedObjectiveIds); + return secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) => + completed.has(objectiveId) + ); +} + +export function secondBattleReliefObjectiveProgressId( + objectiveId: SecondBattleReliefObjectiveId +) { + return `${secondBattleReliefVisitId}:objective:${objectiveId}`; +} + +export function completedSecondBattleReliefObjectiveIds( + completedCampVisitIds: readonly string[] +) { + const completed = new Set(completedCampVisitIds); + return secondBattleReliefObjectiveIds.filter((objectiveId) => + completed.has(secondBattleReliefObjectiveProgressId(objectiveId)) + ); +} diff --git a/src/game/data/secondBattleReliefMemory.ts b/src/game/data/secondBattleReliefMemory.ts new file mode 100644 index 0000000..0135bf3 --- /dev/null +++ b/src/game/data/secondBattleReliefMemory.ts @@ -0,0 +1,246 @@ +import type { BattleScenarioId } from './battles'; +import { + firstPursuitScoutChoiceIds, + firstPursuitScoutVisitId, + isFirstPursuitScoutChoiceId, + type FirstPursuitScoutChoiceId +} from './firstPursuitScoutMemory'; +import { + getSecondBattleReliefChoice, + isSecondBattleReliefChoiceId, + secondBattleReliefSourceBattleId, + secondBattleReliefSourceObjectiveIds, + secondBattleReliefTargetBattleId, + secondBattleReliefVisitId, + type SecondBattleReliefChoiceId, + type SecondBattleReliefThirdBattleEffect +} from './secondBattleReliefExploration'; +import type { + CampaignBattleSettlement, + CampaignState +} from '../state/campaignState'; + +export type SecondBattleReliefCampaignState = Pick< + CampaignState, + 'battleHistory' | 'completedCampVisits' | 'campVisitChoiceIds' +>; + +export type SecondBattleReliefScoutReview = { + choiceId?: FirstPursuitScoutChoiceId; + focus: 'river' | 'village' | 'unrecorded'; + outcome: 'confirmed' | 'missed' | 'unrecorded'; + line: string; +}; + +export type SecondBattleReliefContext = { + sourceBattleId: typeof secondBattleReliefSourceBattleId; + sourceBattleTurn: number; + villageObjectiveAchieved: boolean; + quickObjectiveAchieved: boolean; + scoutReview: SecondBattleReliefScoutReview; + arrivalLine: string; +}; + +export type ResolvedSecondBattleReliefEffect = + SecondBattleReliefThirdBattleEffect & { + readiness: 'reinforced' | 'recovered'; + sourceObjectiveAchieved: boolean; + }; + +export type SecondBattleReliefMemory = { + sourceBattleId: typeof secondBattleReliefSourceBattleId; + targetBattleId: typeof secondBattleReliefTargetBattleId; + visitId: typeof secondBattleReliefVisitId; + choiceId: SecondBattleReliefChoiceId; + choiceLabel: string; + sourceBattleTurn: number; + villageObjectiveAchieved: boolean; + quickObjectiveAchieved: boolean; + scoutReview: SecondBattleReliefScoutReview; + effect: ResolvedSecondBattleReliefEffect; + campSummaryLine: string; + openingLines: [string, string, string]; +}; + +export function resolveSecondBattleReliefContext( + campaign: SecondBattleReliefCampaignState +): SecondBattleReliefContext | undefined { + const settlement = + campaign.battleHistory[secondBattleReliefSourceBattleId]; + if (!validSecondBattleVictory(settlement)) { + return undefined; + } + + const villageObjectiveAchieved = objectiveAchieved( + settlement, + secondBattleReliefSourceObjectiveIds.village + ); + const quickObjectiveAchieved = objectiveAchieved( + settlement, + secondBattleReliefSourceObjectiveIds.quick + ); + const scoutReview = resolveFirstPursuitScoutReview( + campaign, + villageObjectiveAchieved, + quickObjectiveAchieved + ); + + return { + sourceBattleId: secondBattleReliefSourceBattleId, + sourceBattleTurn: settlement.turnNumber, + villageObjectiveAchieved, + quickObjectiveAchieved, + scoutReview, + arrivalLine: villageObjectiveAchieved + ? '전투에서 지킨 북쪽 마을은 주민이 돌아오기 시작했지만, 부상자와 빼앗긴 곡식은 아직 현장에 남아 있습니다.' + : '두령은 쓰러졌지만 북쪽 마을 어귀를 온전히 지키지 못했습니다. 떠나기 전에 주민의 귀환로와 남은 위협부터 수습해야 합니다.' + }; +} + +export function resolveSecondBattleReliefMemory(options: { + campaign: SecondBattleReliefCampaignState; + battleId: BattleScenarioId; +}): SecondBattleReliefMemory | undefined { + if (options.battleId !== secondBattleReliefTargetBattleId) { + return undefined; + } + + const context = resolveSecondBattleReliefContext(options.campaign); + if ( + !context || + !options.campaign.completedCampVisits.includes( + secondBattleReliefVisitId + ) + ) { + return undefined; + } + + const choiceId = + options.campaign.campVisitChoiceIds[secondBattleReliefVisitId]; + if (!isSecondBattleReliefChoiceId(choiceId)) { + return undefined; + } + const choice = getSecondBattleReliefChoice(choiceId); + if (!choice) { + return undefined; + } + + const sourceObjectiveAchieved = + choice.thirdBattleEffect.kind === 'village-supply-line' + ? context.villageObjectiveAchieved + : context.quickObjectiveAchieved; + const readiness = sourceObjectiveAchieved + ? 'reinforced' + : 'recovered'; + const effect = cloneEffect(choice.thirdBattleEffect, { + readiness, + sourceObjectiveAchieved + }); + const readinessLabel = readiness === 'reinforced' + ? '전투 성과를 온전히 이어받음' + : '현장 수습으로 놓친 준비를 보완함'; + const campSummaryLine = + `현장 수습 · ${effect.label} · ${readinessLabel}`; + + return { + sourceBattleId: secondBattleReliefSourceBattleId, + targetBattleId: secondBattleReliefTargetBattleId, + visitId: secondBattleReliefVisitId, + choiceId, + choiceLabel: choice.label, + sourceBattleTurn: context.sourceBattleTurn, + villageObjectiveAchieved: context.villageObjectiveAchieved, + quickObjectiveAchieved: context.quickObjectiveAchieved, + scoutReview: { ...context.scoutReview }, + effect, + campSummaryLine, + openingLines: [ + campSummaryLine, + ...effect.openingLines + ] + }; +} + +function validSecondBattleVictory( + settlement?: CampaignBattleSettlement +): settlement is CampaignBattleSettlement { + return Boolean( + settlement && + settlement.battleId === secondBattleReliefSourceBattleId && + settlement.outcome === 'victory' + ); +} + +function objectiveAchieved( + settlement: CampaignBattleSettlement, + objectiveId: string +) { + return settlement.objectives.some( + (objective) => + objective.id === objectiveId && + objective.achieved === true && + objective.status !== 'failed' + ); +} + +function resolveFirstPursuitScoutReview( + campaign: SecondBattleReliefCampaignState, + villageObjectiveAchieved: boolean, + quickObjectiveAchieved: boolean +): SecondBattleReliefScoutReview { + const choiceId = + campaign.completedCampVisits.includes(firstPursuitScoutVisitId) + ? campaign.campVisitChoiceIds[firstPursuitScoutVisitId] + : undefined; + if (!isFirstPursuitScoutChoiceId(choiceId)) { + return { + focus: 'unrecorded', + outcome: 'unrecorded', + line: '이전 정찰 방침은 장부에 남아 있지 않습니다. 이번 현장 확인을 광종 진군의 첫 기록으로 삼습니다.' + }; + } + + if (choiceId === firstPursuitScoutChoiceIds[0]) { + return quickObjectiveAchieved + ? { + choiceId, + focus: 'river', + outcome: 'confirmed', + line: '간옹이 먼저 표시한 강가 매복로를 빠르게 끊어 나루의 전령 흔적이 선명하게 남았습니다.' + } + : { + choiceId, + focus: 'river', + outcome: 'missed', + line: '강가 매복로는 찾았지만 결착이 늦어 전령의 흔적이 흐려졌습니다. 사공의 증언으로 빠진 길을 보완해야 합니다.' + }; + } + + return villageObjectiveAchieved + ? { + choiceId, + focus: 'village', + outcome: 'confirmed', + line: '간옹이 먼저 표시한 마을 구호로를 지켜 주민의 귀환과 후방 보급을 바로 시작할 수 있습니다.' + } + : { + choiceId, + focus: 'village', + outcome: 'missed', + line: '마을 구호로를 먼저 표시했지만 전투에서 어귀를 온전히 지키지 못했습니다. 떠나기 전에 그 약속을 직접 수습해야 합니다.' + }; +} + +function cloneEffect( + effect: SecondBattleReliefThirdBattleEffect, + resolution: Pick< + ResolvedSecondBattleReliefEffect, + 'readiness' | 'sourceObjectiveAchieved' + > +): ResolvedSecondBattleReliefEffect { + return { + ...effect, + ...resolution, + openingLines: [...effect.openingLines] + } as ResolvedSecondBattleReliefEffect; +} diff --git a/src/game/input/ExplorationInputController.ts b/src/game/input/ExplorationInputController.ts new file mode 100644 index 0000000..9577dc2 --- /dev/null +++ b/src/game/input/ExplorationInputController.ts @@ -0,0 +1,145 @@ +import Phaser from 'phaser'; + +export type ExplorationMovementDirection = Readonly<{ + x: number; + y: number; +}>; + +export type ExplorationInputControllerOptions = { + captureNavigationKeys?: boolean; +}; + +const interactionKeyCodes = [ + Phaser.Input.Keyboard.KeyCodes.E, + Phaser.Input.Keyboard.KeyCodes.SPACE, + Phaser.Input.Keyboard.KeyCodes.ENTER +] as const; + +const capturedNavigationKeyCodes = [ + Phaser.Input.Keyboard.KeyCodes.UP, + Phaser.Input.Keyboard.KeyCodes.DOWN, + Phaser.Input.Keyboard.KeyCodes.LEFT, + Phaser.Input.Keyboard.KeyCodes.RIGHT, + Phaser.Input.Keyboard.KeyCodes.SPACE +] as const; + +export class ExplorationInputController { + private readonly keyboard?: Phaser.Input.Keyboard.KeyboardPlugin; + private readonly cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys; + private readonly moveKeys?: { + up: Phaser.Input.Keyboard.Key; + down: Phaser.Input.Keyboard.Key; + left: Phaser.Input.Keyboard.Key; + right: Phaser.Input.Keyboard.Key; + }; + private readonly interactKeys: Phaser.Input.Keyboard.Key[] = []; + private readonly keyDownBindings: Array<{ + key: Phaser.Input.Keyboard.Key; + callback: () => void; + }> = []; + private interactionQueued = false; + + constructor( + scene: Phaser.Scene, + { captureNavigationKeys = true }: ExplorationInputControllerOptions = {} + ) { + const keyboard = scene.input.keyboard ?? undefined; + this.keyboard = keyboard; + if (!keyboard) { + return; + } + + this.cursorKeys = keyboard.createCursorKeys(); + this.moveKeys = { + up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), + down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), + left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), + right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) + }; + interactionKeyCodes.forEach((keyCode) => { + const key = keyboard.addKey(keyCode); + this.onKeyDown(key, () => { + this.interactionQueued = true; + }); + this.interactKeys.push(key); + }); + if (captureNavigationKeys) { + keyboard.addCapture([...capturedNavigationKeyCodes]); + } + } + + bindKey(keyCode: number, onDown: () => void) { + const key = this.keyboard?.addKey(keyCode); + if (key) { + this.onKeyDown(key, onDown); + } + return this; + } + + onMovementKeyDown( + callback: (direction: ExplorationMovementDirection) => void + ) { + const bindings = [ + { key: this.cursorKeys?.up, direction: { x: 0, y: -1 } }, + { key: this.cursorKeys?.down, direction: { x: 0, y: 1 } }, + { key: this.cursorKeys?.left, direction: { x: -1, y: 0 } }, + { key: this.cursorKeys?.right, direction: { x: 1, y: 0 } }, + { key: this.moveKeys?.up, direction: { x: 0, y: -1 } }, + { key: this.moveKeys?.down, direction: { x: 0, y: 1 } }, + { key: this.moveKeys?.left, direction: { x: -1, y: 0 } }, + { key: this.moveKeys?.right, direction: { x: 1, y: 0 } } + ] as const; + bindings.forEach(({ key, direction }) => { + if (key) { + this.onKeyDown(key, () => callback(direction)); + } + }); + return this; + } + + destroy() { + this.keyDownBindings.forEach(({ key, callback }) => { + key.off('down', callback); + }); + this.keyDownBindings.length = 0; + this.interactionQueued = false; + } + + discardInteractionRequest() { + this.interactionQueued = false; + } + + consumeInteractionRequest() { + const keyPressedThisFrame = this.interactKeys.some((key) => + Phaser.Input.Keyboard.JustDown(key) + ); + const requested = this.interactionQueued || keyPressedThisFrame; + this.interactionQueued = false; + return requested; + } + + movementDirection() { + const vector = new Phaser.Math.Vector2(); + if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) { + vector.x -= 1; + } + if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) { + vector.x += 1; + } + if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) { + vector.y -= 1; + } + if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) { + vector.y += 1; + } + return vector; + } + + private onKeyDown( + key: Phaser.Input.Keyboard.Key, + callback: () => void + ) { + key.on('down', callback); + this.keyDownBindings.push({ key, callback }); + } +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e2b284f..51c2335 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -128,6 +128,10 @@ import { resolveFirstPursuitScoutMemory, type FirstPursuitScoutMemory } from '../data/firstPursuitScoutMemory'; +import { + resolveSecondBattleReliefMemory, + type SecondBattleReliefMemory +} from '../data/secondBattleReliefMemory'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -3898,6 +3902,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; private firstBattlePreparation: FirstBattlePreparationState = { ...emptyFirstBattlePreparationState }; private firstPursuitScoutMemory?: FirstPursuitScoutMemory; + private secondBattleReliefMemory?: SecondBattleReliefMemory; private prologueVolunteerReassured = false; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; @@ -4010,6 +4015,10 @@ export class BattleScene extends Phaser.Scene { campaign, battleId: battleScenario.id }); + this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({ + campaign, + battleId: battleScenario.id + }); this.prologueVolunteerReassured = campaign.completedTutorialIds.includes( prologueMilitiaCampCampaignTutorialIds.reassureVolunteer ); @@ -8594,6 +8603,11 @@ export class BattleScene extends Phaser.Scene { if (this.firstPursuitScoutMemory) { return `${this.firstPursuitScoutMemory.campSummaryLine}. 간옹이 표시한 적의 첫 움직임을 보며 전열을 조정하세요.`; } + if (this.secondBattleReliefMemory) { + return this.secondBattleReliefMemory.effect.kind === 'village-supply-line' + ? `${this.secondBattleReliefMemory.campSummaryLine}. 유비를 후방에 두면 마을에서 챙긴 콩 1개를 전투 중 사용할 수 있습니다.` + : `${this.secondBattleReliefMemory.campSummaryLine}. 표시된 전령 마원의 첫 움직임을 보고 장비의 측면 출발 위치를 조정하세요.`; + } return '추천 전열이 적용되어 있습니다. 장수를 선택한 뒤 시작 구역 안에서 위치를 바꿀 수 있습니다.'; } @@ -8626,6 +8640,15 @@ export class BattleScene extends Phaser.Scene { })(); return `${this.firstPursuitScoutMemory.campSummaryLine}\n${strategySummary}`; } + if (this.secondBattleReliefMemory) { + const strategySummary = this.launchSortieRecommendation + ? `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}` + : (() => { + const order = sortieOrderDefinition(this.launchSortieOrderId); + return `첫 군령 · ${order.label} · ${order.summary}`; + })(); + return `${this.secondBattleReliefMemory.campSummaryLine}\n${strategySummary}`; + } if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; } @@ -9677,7 +9700,11 @@ export class BattleScene extends Phaser.Scene { this.firstBattlePreparation.eligible && this.firstBattlePreparation.scouting ) || - this.firstPursuitScoutMemory?.deploymentIntentPreview === true + this.firstPursuitScoutMemory?.deploymentIntentPreview === true || + ( + this.secondBattleReliefMemory?.effect.kind === 'ferry-messenger-intel' && + this.secondBattleReliefMemory.effect.revealTrackedRoute === true + ) ) && !this.battleOutcome ) { @@ -9690,6 +9717,31 @@ export class BattleScene extends Phaser.Scene { }); } + private trackedDeploymentIntentEnemyId() { + if ( + this.phase !== 'deployment' || + ( + this.firstBattlePreparation.eligible && + this.firstBattlePreparation.scouting + ) || + this.firstPursuitScoutMemory?.deploymentIntentPreview === true || + this.secondBattleReliefMemory?.effect.kind !== + 'ferry-messenger-intel' || + this.secondBattleReliefMemory.effect.revealTrackedRoute !== true + ) { + return undefined; + } + return this.secondBattleReliefMemory.effect.trackedEnemyUnitId; + } + + private enemyIntentForecastVisibleForUnit(unitId: string) { + if (!this.enemyIntentForecastEnabled()) { + return false; + } + const trackedEnemyId = this.trackedDeploymentIntentEnemyId(); + return !trackedEnemyId || trackedEnemyId === unitId; + } + private enemyIntentCounterplayEnabled() { return isEnemyIntentCounterplayAvailable({ phase: this.phase, @@ -9765,7 +9817,10 @@ export class BattleScene extends Phaser.Scene { return; } - const plans = this.currentEnemyActionPlans(); + const trackedEnemyId = this.trackedDeploymentIntentEnemyId(); + const plans = this.currentEnemyActionPlans().filter( + (plan) => !trackedEnemyId || plan.enemy.id === trackedEnemyId + ); plans.forEach((plan) => this.enemyIntentForecasts.set(plan.enemy.id, plan)); plans @@ -17295,6 +17350,17 @@ export class BattleScene extends Phaser.Scene { guanYuStocks.set('salve', Math.min(2, (guanYuStocks.get('salve') ?? 0) + 1)); } } + if (this.secondBattleReliefMemory?.effect.kind === 'village-supply-line') { + const { carriedUsableCount, carriedUsableId, recommendedUnitId } = + this.secondBattleReliefMemory.effect; + const carrierStocks = stocksByUnit.get(recommendedUnitId); + if (carrierStocks) { + carrierStocks.set( + carriedUsableId, + (carrierStocks.get(carriedUsableId) ?? 0) + carriedUsableCount + ); + } + } return stocksByUnit; } @@ -17867,6 +17933,7 @@ export class BattleScene extends Phaser.Scene { ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] : []), ...(this.firstPursuitScoutMemory?.openingLines ?? []), + ...(this.secondBattleReliefMemory?.openingLines ?? []), ...cityInformationLines, enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined), ...(battleScenario.id === 'first-battle-zhuo-commandery' @@ -18182,9 +18249,11 @@ export class BattleScene extends Phaser.Scene { const { title, lines } = event; const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines()); const firstPursuitScoutLines = new Set(this.firstPursuitScoutMemory?.openingLines ?? []); + const secondBattleReliefLines = new Set(this.secondBattleReliefMemory?.openingLines ?? []); const doctrineLines = lines.filter((line) => ( completedCityInformationLines.has(line) || firstPursuitScoutLines.has(line) || + secondBattleReliefLines.has(line) || line.startsWith('도원 공명') || line.startsWith('핵심 공명') || line.startsWith('공명 ') || @@ -19420,6 +19489,10 @@ export class BattleScene extends Phaser.Scene { campaign: getCampaignState(), battleId: battleScenario.id }); + this.secondBattleReliefMemory = resolveSecondBattleReliefMemory({ + campaign: getCampaignState(), + battleId: battleScenario.id + }); this.refreshLaunchCamaraderieMemoryBonus(getCampaignState()); if (this.launchSortieResonanceBondId) { this.restoreCoreResonancePursuitStats(state.coreResonanceStats); @@ -27777,7 +27850,10 @@ export class BattleScene extends Phaser.Scene { const terrainRule = getTerrainRule(terrain); const terrainValue = this.terrainEffectText(terrain, unit, 'card'); - if (unit.faction === 'enemy' && this.enemyIntentForecastEnabled()) { + if ( + unit.faction === 'enemy' && + this.enemyIntentForecastVisibleForUnit(unit.id) + ) { const plan = this.enemyIntentForecasts.get(unit.id) ?? this.buildEnemyActionPlan(unit); effects.push({ icon: this.enemyActionPlanIcon(plan), @@ -29020,6 +29096,70 @@ export class BattleScene extends Phaser.Scene { }; } + private secondBattleReliefMemoryDebugState() { + const memory = this.secondBattleReliefMemory; + if (!memory) { + return { + active: false, + targetBattleId: 'third-battle-guangzong-road', + choiceId: null, + effectKind: null, + openingLines: [] + }; + } + + const effect = memory.effect; + const villageSupply = effect.kind === 'village-supply-line' + ? { + active: true, + carrierUnitId: effect.recommendedUnitId, + recommendedRole: effect.recommendedRole, + itemId: effect.carriedUsableId, + carriedCount: effect.carriedUsableCount, + battleStock: this.itemStock(effect.recommendedUnitId, effect.carriedUsableId) + } + : { + active: false + }; + const ferryIntel = effect.kind === 'ferry-messenger-intel' + ? { + active: true, + recommendedUnitId: effect.recommendedUnitId, + recommendedRole: effect.recommendedRole, + trackedEnemyUnitId: effect.trackedEnemyUnitId, + revealTrackedRoute: effect.revealTrackedRoute, + forecastVisibleDuringDeployment: + this.phase === 'deployment' && + this.enemyIntentForecastEnabled() && + this.enemyIntentForecasts.has(effect.trackedEnemyUnitId), + trackedIntent: this.enemyIntentForecasts.has(effect.trackedEnemyUnitId) + ? this.enemyActionPlanDebugValue(this.enemyIntentForecasts.get(effect.trackedEnemyUnitId)!) + : null + } + : { + active: false + }; + + return { + active: true, + sourceBattleId: memory.sourceBattleId, + targetBattleId: memory.targetBattleId, + visitId: memory.visitId, + choiceId: memory.choiceId, + choiceLabel: memory.choiceLabel, + campSummaryLine: memory.campSummaryLine, + effectId: effect.id, + effectKind: effect.kind, + effectLabel: effect.label, + readiness: effect.readiness, + sourceObjectiveAchieved: effect.sourceObjectiveAchieved, + focusObjectiveId: effect.focusObjectiveId, + villageSupply, + ferryIntel, + openingLines: [...memory.openingLines] + }; + } + private firstBattleVolunteerPromiseDebugState() { const line = volunteerPromiseLineForBattle( battleScenario.id, @@ -29170,6 +29310,7 @@ export class BattleScene extends Phaser.Scene { battleHud: this.battleHudDebugState(), firstBattlePreparation: this.firstBattlePreparationDebugState(), firstPursuitScoutMemory: this.firstPursuitScoutMemoryDebugState(), + secondBattleReliefMemory: this.secondBattleReliefMemoryDebugState(), firstBattleNarrativeMemory: { volunteerPromise: this.firstBattleVolunteerPromiseDebugState() }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d30a9eb..8f11fa3 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -39,6 +39,11 @@ import { resolveFirstPursuitScoutMemory } from '../data/firstPursuitScoutMemory'; import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit'; +import { + secondBattleReliefExplorationDefinition, + secondBattleReliefSourceBattleId, + secondBattleReliefVisitId +} from '../data/secondBattleReliefExploration'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -231,6 +236,7 @@ import { collectCityStayInformation, purchaseCityStayEquipment } from '../state/cityStayActions'; +import { secondBattleReliefProgress } from '../state/secondBattleReliefActions'; import { clearCampaignBattleSavesForSlot } from '../state/battleSaveKeys'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { palette } from '../ui/palette'; @@ -300,6 +306,11 @@ type CampVisitChoice = { itemRewards?: string[]; }; +const directExplorationVisitIds = new Set([ + firstPursuitScoutVisitId, + secondBattleReliefVisitId +]); + type CampTabButtonView = { tab: CampTab; bg: Phaser.GameObjects.Rectangle; @@ -8410,6 +8421,26 @@ const campVisits: CampVisitDefinition[] = [ itemRewards: [...choice.itemRewards] })) }, + { + id: secondBattleReliefExplorationDefinition.id, + title: secondBattleReliefExplorationDefinition.title, + location: secondBattleReliefExplorationDefinition.location, + availableAfterBattleIds: [secondBattleReliefSourceBattleId], + availableDuringSteps: ['second-camp'], + description: secondBattleReliefExplorationDefinition.description, + lines: secondBattleReliefExplorationDefinition.introLines.map( + (line) => `${line.speaker}: ${line.text}` + ), + choices: secondBattleReliefExplorationDefinition.choices.map( + (choice) => ({ + id: choice.id, + label: choice.label, + response: choice.response, + bondExp: choice.bondExp, + itemRewards: [...choice.itemRewards] + }) + ) + }, { id: 'jingzhou-scholar-rumors', title: '형주 선비들의 모임', @@ -11389,6 +11420,7 @@ export class CampScene extends Phaser.Scene { private campDialogueBodyText?: Phaser.GameObjects.Text; private selectedVisitId = campVisits[0].id; private firstPursuitExplorationButton?: Phaser.GameObjects.Rectangle; + private secondBattleReliefExplorationButton?: Phaser.GameObjects.Rectangle; private equipmentInventoryPage = 0; private equipmentPanelBackground?: Phaser.GameObjects.Rectangle; private equipmentInventoryPreviousButton?: Phaser.GameObjects.Rectangle; @@ -11514,6 +11546,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueChoiceButtons = {}; this.campDialogueBodyText = undefined; this.firstPursuitExplorationButton = undefined; + this.secondBattleReliefExplorationButton = undefined; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -11625,12 +11658,13 @@ export class CampScene extends Phaser.Scene { } else if (this.activeTab === 'city') { this.activeTab = 'status'; } - const pendingFirstPursuitScoutVisit = this.availableCampVisits().some( + const pendingDirectExplorationVisit = this.availableCampVisits().find( (visit) => - visit.id === firstPursuitScoutVisitId && + directExplorationVisitIds.has(visit.id) && !this.completedCampVisits().includes(visit.id) ); - if (!cityStay && pendingFirstPursuitScoutVisit && this.activeTab === 'status') { + if (!cityStay && pendingDirectExplorationVisit) { + this.selectedVisitId = pendingDirectExplorationVisit.id; this.activeTab = 'visit'; } soundDirector.playSoundscape({ @@ -23361,20 +23395,32 @@ export class CampScene extends Phaser.Scene { return; } - if (visit.id === firstPursuitScoutVisitId) { + if (directExplorationVisitIds.has(visit.id)) { + const secondRelief = visit.id === secondBattleReliefVisitId; + const reliefProgress = secondRelief && this.campaign + ? secondBattleReliefProgress(this.campaign) + : undefined; const guide = this.track(this.add.rectangle(x + 18, y + height - 102, width - 36, 84, 0x151f2a, 0.96)); guide.setOrigin(0); guide.setStrokeStyle(1, palette.blue, 0.62); this.track(this.add.text( x + 32, y + height - 90, - '유비를 움직여 간옹에게 직접 말을 거세요.', + secondRelief + ? reliefProgress?.choiceReady + ? '현장 확인 완료 · 간옹과 광종 진군 방침을 정하세요.' + : reliefProgress && reliefProgress.completedObjectiveIds.length > 0 + ? `유비를 움직여 현장 수습을 이어가세요 · ${Math.min(3, reliefProgress.completedObjectiveIds.length)}/3 기록됨` + : '유비를 움직여 북쪽 마을과 나루를 직접 살피세요.' + : '유비를 움직여 간옹에게 직접 말을 거세요.', this.textStyle(13, '#d4dce6', true) )); this.track(this.add.text( x + 32, y + height - 68, - 'WASD·방향키 이동 · E/Space 대화', + secondRelief + ? '관우 → 사공 → 장비 → 간옹 · 진행 자동 저장' + : 'WASD·방향키 이동 · E/Space 대화', this.textStyle(11, '#9fb0bf') )); @@ -23389,12 +23435,22 @@ export class CampScene extends Phaser.Scene { button.on('pointerover', () => button.setFillStyle(0x654c25, 1)); button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.98)); button.on('pointerdown', openExploration); - this.firstPursuitExplorationButton = button; + if (secondRelief) { + this.secondBattleReliefExplorationButton = button; + } else { + this.firstPursuitExplorationButton = button; + } const label = this.track(this.add.text( x + width / 2, buttonY, - '정찰막으로 이동', + secondRelief + ? reliefProgress?.choiceReady + ? '광종 진군 방침 결정' + : reliefProgress && reliefProgress.completedObjectiveIds.length > 0 + ? '현장 수습 이어가기' + : '북쪽 마을·나루로 이동' + : '정찰막으로 이동', this.textStyle(14, '#fff2b8', true) )); label.setOrigin(0.5); @@ -24942,6 +24998,7 @@ export class CampScene extends Phaser.Scene { this.campDialogueChoiceButtons = {}; this.campDialogueBodyText = undefined; this.firstPursuitExplorationButton = undefined; + this.secondBattleReliefExplorationButton = undefined; this.campRosterLayout = undefined; this.cityPanelBackground = undefined; this.cityExploreButton = undefined; @@ -25102,6 +25159,14 @@ export class CampScene extends Phaser.Scene { (visit) => visit.id === firstPursuitScoutVisitId ); const firstPursuitScoutMemory = this.firstPursuitScoutMemoryForNextSortie(); + const secondBattleReliefVisit = this.availableCampVisits().find( + (visit) => visit.id === secondBattleReliefVisitId + ); + const secondReliefProgress = this.campaign + ? secondBattleReliefProgress(this.campaign) + : undefined; + const secondReliefChoiceId = + this.campaign?.campVisitChoiceIds[secondBattleReliefVisitId] ?? null; const availableCampDialogues = this.availableCampDialogues(); const selectedCampDialogue = availableCampDialogues.find( (dialogue) => dialogue.id === this.selectedDialogueId @@ -25289,6 +25354,39 @@ export class CampScene extends Phaser.Scene { openingLines: firstPursuitScoutMemory ? [...firstPursuitScoutMemory.openingLines] : [], briefingSummaryBounds: this.sortieTextBoundsDebug(this.sortieBriefingScoutSummaryText) }, + secondBattleReliefExploration: { + visitId: secondBattleReliefVisitId, + sourceBattleId: secondBattleReliefSourceBattleId, + available: Boolean(secondBattleReliefVisit), + selected: this.selectedVisitId === secondBattleReliefVisitId, + completed: Boolean(secondReliefProgress?.completed), + mode: secondReliefProgress?.completed + ? 'completed' + : secondBattleReliefVisit + ? secondReliefProgress && + secondReliefProgress.completedObjectiveIds.length > 0 + ? 'resume-walkable-relief' + : 'walkable-relief' + : 'unavailable', + explorationButtonBounds: this.sortieInteractiveObjectBoundsDebug( + this.secondBattleReliefExplorationButton + ), + explorationInteractive: Boolean( + this.secondBattleReliefExplorationButton?.input?.enabled + ), + completedObjectiveIds: + secondReliefProgress?.completedObjectiveIds ?? [], + nextObjectiveId: secondReliefProgress?.nextObjectiveId ?? null, + choiceReady: secondReliefProgress?.choiceReady ?? false, + choiceId: secondReliefChoiceId, + choices: + secondBattleReliefVisit?.choices.map((choice) => ({ + id: choice.id, + label: choice.label, + response: choice.response, + rewardText: this.visitRewardText(choice) + })) ?? [] + }, selectedDialogue: selectedCampDialogue ? { id: selectedCampDialogue.id, diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index 0ffb3d1..76ec0e1 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import campBackgroundUrl from '../../assets/images/exploration/prologue-militia-camp.webp'; +import secondReliefBackgroundUrl from '../../assets/images/exploration/second-pursuit-village-ferry.webp'; import { soundDirector } from '../audio/SoundDirector'; import { ensureExplorationCharacterAnimations, @@ -19,13 +20,29 @@ import { getFirstPursuitScoutVisitChoice, type FirstPursuitScoutVisitChoice } from '../data/firstPursuitScoutVisit'; +import { + getSecondBattleReliefChoice, + getSecondBattleReliefObjective, + secondBattleReliefExplorationDefinition, + secondBattleReliefSourceBattleId, + secondBattleReliefVisitId, + type SecondBattleReliefChoice, + type SecondBattleReliefObjectiveId +} from '../data/secondBattleReliefExploration'; +import { resolveSecondBattleReliefContext } from '../data/secondBattleReliefMemory'; import { portraitAssetEntryForStoryContext, type PortraitAssetEntry } from '../data/portraitAssets'; +import { ExplorationInputController } from '../input/ExplorationInputController'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { getCampaignState } from '../state/campaignState'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; +import { + completeSecondBattleReliefExploration, + recordSecondBattleReliefObjective, + secondBattleReliefProgress +} from '../state/secondBattleReliefActions'; import { startGameScene } from './lazyScenes'; const sceneWidth = 1920; @@ -44,6 +61,7 @@ const navigationProbeStep = 18; const directPointerSnapRadius = 96; const transitionFadeDurationMs = 320; const campBackgroundKey = 'first-pursuit-camp-background'; +const secondReliefBackgroundKey = 'second-pursuit-village-ferry-background'; const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; @@ -51,18 +69,26 @@ type CampVisitExplorationSceneData = { visitId?: string; }; -type ExplorationActorId = 'jian-yong' | 'guan-yu' | 'zhang-fei'; +type ExplorationActorId = + | 'jian-yong' + | 'guan-yu' + | 'zhang-fei' + | 'guan-yu-village' + | 'north-ferry-boatman' + | 'zhang-fei-ferry' + | 'jian-yong-records'; type ExplorationActorDefinition = { id: ExplorationActorId; name: string; role: string; textureKey: ExplorationCharacterTextureKey; - portraitKey: string; + portraitKey?: string; x: number; y: number; direction: ExplorationCharacterDirection; kind: 'objective' | 'companion'; + objectiveId?: SecondBattleReliefObjectiveId; dialogue: readonly DialogueLine[]; repeatDialogue?: readonly DialogueLine[]; }; @@ -106,11 +132,15 @@ type DialogueState = { }; type ChoiceView = { - choice: FirstPursuitScoutVisitChoice; + choice: VisitChoice; button: Phaser.GameObjects.Rectangle; }; -const actorDefinitions: readonly ExplorationActorDefinition[] = [ +type VisitChoice = + | FirstPursuitScoutVisitChoice + | SecondBattleReliefChoice; + +const firstPursuitActorDefinitions: readonly ExplorationActorDefinition[] = [ { id: 'jian-yong', name: '간옹', @@ -182,32 +212,58 @@ const actorDefinitions: readonly ExplorationActorDefinition[] = [ } ] as const; -const characterTextureKeys = [ - playerTextureKey, - ...actorDefinitions.map((actor) => actor.textureKey) -] as const; +const secondReliefActorDefinitions: readonly ExplorationActorDefinition[] = + secondBattleReliefExplorationDefinition.actors.map((actor) => { + const objective = secondBattleReliefExplorationDefinition.objectives.find( + (candidate) => candidate.targetActorId === actor.id + ); + const portraitKey = actor.portraitKey; + const withSpeakerPortrait = (line: DialogueLine): DialogueLine => ({ + ...line, + portraitKey: + line.portraitKey ?? + (line.speaker === actor.name ? portraitKey : undefined) + }); + return { + ...actor, + portraitKey, + dialogue: actor.dialogue.map(withSpeakerPortrait), + repeatDialogue: actor.repeatDialogue.map(withSpeakerPortrait), + kind: 'objective' as const, + objectiveId: objective?.id + }; + }); + const portraitKeys = [ 'liu-bei-yellow-turban', - ...actorDefinitions.map((actor) => actor.portraitKey) -] as const; -const portraitContext = { + ...firstPursuitActorDefinitions.map((actor) => actor.portraitKey), + ...secondReliefActorDefinitions.map((actor) => actor.portraitKey) +].filter((key): key is string => Boolean(key)); +const firstPursuitPortraitContext = { id: 'first-pursuit-camp-exploration', background: 'story-militia' } as const; -const campExit = { +const secondReliefPortraitContext = { + id: 'second-pursuit-aftermath-relief-exploration', + background: 'story-camp' +} as const; +const firstPursuitCampExit = { id: 'camp-exit' as const, name: '군영 장부로 돌아가기', x: 780, y: 895 }; - -function explorationPortraitEntries() { +function explorationPortraitEntries( + portraitContext: + | typeof firstPursuitPortraitContext + | typeof secondReliefPortraitContext +) { return [...new Set(portraitKeys)] .map((portraitKey) => portraitAssetEntryForStoryContext( portraitKey, portraitContext, - `first-pursuit-${portraitKey}` + `${portraitContext.id}-${portraitKey}` ) ) .filter((entry): entry is PortraitAssetEntry => entry !== undefined); @@ -242,14 +298,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { private choiceReadyAt = Number.POSITIVE_INFINITY; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; - private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys; - private moveKeys?: { - up: Phaser.Input.Keyboard.Key; - down: Phaser.Input.Keyboard.Key; - left: Phaser.Input.Keyboard.Key; - right: Phaser.Input.Keyboard.Key; - }; - private interactKeys: Phaser.Input.Keyboard.Key[] = []; + private explorationInput?: ExplorationInputController; private moveTarget?: Phaser.Math.Vector2; private moveWaypoints: Phaser.Math.Vector2[] = []; private moveTargetId?: InteractionTarget['id']; @@ -259,7 +308,6 @@ export class CampVisitExplorationScene extends Phaser.Scene { private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; - private interactionQueued = false; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; @@ -270,13 +318,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { } init(data?: CampVisitExplorationSceneData) { - this.visitId = data?.visitId === firstPursuitScoutVisitId - ? data.visitId - : firstPursuitScoutVisitId; + this.visitId = + data?.visitId === secondBattleReliefVisitId + ? secondBattleReliefVisitId + : firstPursuitScoutVisitId; } preload() { - explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => { + explorationCharacterAssetInfos(this.currentCharacterTextureKeys()).forEach((asset) => { if (!this.textures.exists(asset.key)) { this.load.spritesheet(asset.key, asset.url, { frameWidth: asset.frameWidth, @@ -284,11 +333,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); } }); - if (!this.textures.exists(campBackgroundKey)) { - this.ownedPresentationTextureKeys.add(campBackgroundKey); - this.load.image(campBackgroundKey, campBackgroundUrl); + const backgroundKey = this.currentBackgroundKey(); + if (!this.textures.exists(backgroundKey)) { + this.ownedPresentationTextureKeys.add(backgroundKey); + this.load.image(backgroundKey, this.currentBackgroundUrl()); } - explorationPortraitEntries().forEach(({ textureKey, url }) => { + explorationPortraitEntries(this.currentPortraitContext()).forEach(({ textureKey, url }) => { if (!this.textures.exists(textureKey)) { this.ownedPresentationTextureKeys.add(textureKey); this.load.image(textureKey, url); @@ -302,9 +352,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.clearNavigationTarget(); + this.explorationInput?.destroy(); + this.explorationInput = undefined; this.dialogueState = undefined; this.closeChoicePanel(); - releaseExplorationCharacterTextureKeys(this, characterTextureKeys); + releaseExplorationCharacterTextureKeys( + this, + this.currentCharacterTextureKeys() + ); this.releasePresentationTextures(); }); if (!this.visitAvailable()) { @@ -322,7 +377,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.drawCamp(); this.drawHud(); this.setupInput(); - ensureExplorationCharacterAnimations(this, characterTextureKeys); + ensureExplorationCharacterAnimations( + this, + this.currentCharacterTextureKeys() + ); this.createActors(); this.createDialoguePanel(); this.ready = true; @@ -331,14 +389,25 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.refreshActorMarkers(); this.refreshInteractionPrompt(); - soundDirector.playSoundscape({ - musicKey: 'camp-rally', - ambienceKey: 'campfire-ambience', - musicVolume: 0.22, - ambienceVolume: 0.12 - }); + soundDirector.playSoundscape( + this.isSecondReliefVisit() + ? { + musicKey: 'camp-rally', + ambienceKey: 'riverside-village-day-ambience', + musicVolume: 0.2, + ambienceVolume: 0.11 + } + : { + musicKey: 'camp-rally', + ambienceKey: 'campfire-ambience', + musicVolume: 0.22, + ambienceVolume: 0.12 + } + ); this.showCompletionToast( - '정찰대 막사의 간옹에게 직접 다가가세요.', + this.isSecondReliefVisit() + ? this.reliefGuideMessage() + : '정찰대 막사의 간옹에게 직접 다가가세요.', 'guide', 1850 ); @@ -346,12 +415,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { update(time: number, delta: number) { if (!this.ready || this.navigationPending || time < this.inputReadyAt) { - this.interactionQueued = false; + this.explorationInput?.discardInteractionRequest(); return; } if (this.choicePanel) { this.stopPlayerMovement(); - this.interactionQueued = false; + this.explorationInput?.discardInteractionRequest(); return; } if (this.dialogueState) { @@ -376,8 +445,21 @@ export class CampVisitExplorationScene extends Phaser.Scene { : null; const target = this.nearestInteractionTarget(promptRadius); const completed = this.visitComplete(campaign); - const choiceId = campaign.campVisitChoiceIds[firstPursuitScoutVisitId] ?? null; - const chosen = choiceId ? getFirstPursuitScoutVisitChoice(choiceId) : undefined; + const choiceId = campaign.campVisitChoiceIds[this.visitId] ?? null; + const chosen: VisitChoice | undefined = choiceId + ? this.isSecondReliefVisit() + ? getSecondBattleReliefChoice(choiceId) + : getFirstPursuitScoutVisitChoice(choiceId) + : undefined; + const reliefProgress = this.isSecondReliefVisit() + ? secondBattleReliefProgress(campaign) + : undefined; + const reliefContext = this.isSecondReliefVisit() + ? resolveSecondBattleReliefContext(campaign) + : undefined; + const activeCharacterTextureKeys = this.currentCharacterTextureKeys(); + const activeMovementBounds = this.currentMovementBounds(); + const activeCampExit = this.currentCampExit(); return { scene: this.scene.key, @@ -386,7 +468,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, background: { - key: this.backgroundImage?.texture.key ?? campBackgroundKey, + key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(), ready: this.backgroundImage?.active === true, fallback: this.backgroundFallback, sourceWidth: this.backgroundImage?.frame.realWidth ?? null, @@ -395,11 +477,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { ? this.boundsSnapshot(this.backgroundImage.getBounds()) : null }, - requiredTextures: characterTextureKeys.map((key) => ({ + requiredTextures: activeCharacterTextureKeys.map((key) => ({ key, ready: this.textures.exists(key) })), - requiredTexturesReady: characterTextureKeys.every((key) => + requiredTexturesReady: activeCharacterTextureKeys.every((key) => this.textures.exists(key) ), player: playerPosition @@ -422,7 +504,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { targetId: this.moveTargetId ?? null, lastTargetId: this.lastNavigationTargetId ?? null, waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })), - walkBounds: this.boundsSnapshot(movementBounds), + walkBounds: this.boundsSnapshot(activeMovementBounds), collisionRadius: playerCollisionRadius, detourCount: this.navigationDetourCount }, @@ -461,7 +543,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { Math.abs(sprite.y - initialPosition.y) > 0.1, textureKey: sprite.texture.key, animationKey: sprite.anims.currentAnim?.key ?? null, - completed: definition.kind === 'objective' && completed, + objectiveId: definition.objectiveId ?? null, + completed: + definition.kind === 'objective' && + (definition.objectiveId + ? reliefProgress?.completedObjectiveIds.includes( + definition.objectiveId + ) ?? false + : completed), + current: + definition.objectiveId === reliefProgress?.nextObjectiveId, distance: playerPosition ? Phaser.Math.Distance.Between( playerPosition.x, @@ -474,13 +565,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { }) ), exit: { - ...campExit, + ...activeCampExit, distance: playerPosition ? Phaser.Math.Distance.Between( playerPosition.x, playerPosition.y, - campExit.x, - campExit.y + activeCampExit.x, + activeCampExit.y ) : null }, @@ -526,18 +617,37 @@ export class CampVisitExplorationScene extends Phaser.Scene { id: choice.id, label: choice.label, rewardText: this.choiceRewardText(choice), + effectText: this.choiceEffectText(choice), interactive: Boolean(button.input?.enabled), bounds: this.boundsSnapshot(button.getBounds()) })) }, visit: { - id: firstPursuitScoutVisitId, + id: this.visitId, + mode: this.isSecondReliefVisit() + ? 'second-battle-relief' + : 'first-pursuit-scout', completed, choiceId, choiceLabel: chosen?.label ?? null, itemRewards: chosen ? [...chosen.itemRewards] : [], - bondExp: chosen?.bondExp ?? 0 + bondExp: chosen?.bondExp ?? 0, + completedObjectiveIds: + reliefProgress?.completedObjectiveIds ?? [], + nextObjectiveId: reliefProgress?.nextObjectiveId ?? null, + choiceReady: reliefProgress?.choiceReady ?? !completed }, + storyContext: reliefContext + ? { + sourceBattleId: reliefContext.sourceBattleId, + sourceBattleTurn: reliefContext.sourceBattleTurn, + villageObjectiveAchieved: + reliefContext.villageObjectiveAchieved, + quickObjectiveAchieved: reliefContext.quickObjectiveAchieved, + arrivalLine: reliefContext.arrivalLine, + scoutReview: { ...reliefContext.scoutReview } + } + : null, campaign: { latestBattleId: campaign.latestBattleId ?? null, completedCampVisits: [...campaign.completedCampVisits], @@ -587,13 +697,125 @@ export class CampVisitExplorationScene extends Phaser.Scene { debugChooseScout(choiceId: string) { const choice = getFirstPursuitScoutVisitChoice(choiceId); - if (!choice || this.visitComplete()) { + if ( + this.isSecondReliefVisit() || + !choice || + this.visitComplete() + ) { return false; } this.chooseScout(choice); return true; } + debugRecordReliefObjective(objectiveId: string) { + if ( + !this.isSecondReliefVisit() || + this.navigationPending || + this.visitComplete() + ) { + return false; + } + const result = recordSecondBattleReliefObjective(objectiveId); + if (!result.ok) { + return false; + } + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.refreshInteractionPrompt(); + return true; + } + + debugChooseVisitChoice(choiceId: string) { + if (!this.isSecondReliefVisit()) { + return this.debugChooseScout(choiceId); + } + const choice = getSecondBattleReliefChoice(choiceId); + if ( + !choice || + this.visitComplete() || + !secondBattleReliefProgress().choiceReady + ) { + return false; + } + this.chooseReliefPriority(choice); + return this.visitComplete(); + } + + private isSecondReliefVisit() { + return this.visitId === secondBattleReliefVisitId; + } + + private currentActorDefinitions() { + return this.isSecondReliefVisit() + ? secondReliefActorDefinitions + : firstPursuitActorDefinitions; + } + + private currentCharacterTextureKeys() { + return [ + playerTextureKey, + ...this.currentActorDefinitions().map((actor) => actor.textureKey) + ]; + } + + private currentPortraitContext() { + return this.isSecondReliefVisit() + ? secondReliefPortraitContext + : firstPursuitPortraitContext; + } + + private currentBackgroundKey() { + return this.isSecondReliefVisit() + ? secondReliefBackgroundKey + : campBackgroundKey; + } + + private currentBackgroundUrl() { + return this.isSecondReliefVisit() + ? secondReliefBackgroundUrl + : campBackgroundUrl; + } + + private currentMovementBounds() { + if (!this.isSecondReliefVisit()) { + return movementBounds; + } + const { x, y, width, height } = + secondBattleReliefExplorationDefinition.movementBounds; + return new Phaser.Geom.Rectangle(x, y, width, height); + } + + private currentCampExit() { + return this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.exit + : firstPursuitCampExit; + } + + private currentChoices(): readonly VisitChoice[] { + return this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.choices + : firstPursuitScoutVisitDefinition.choices; + } + + private reliefGuideMessage() { + const progress = secondBattleReliefProgress(); + const objective = progress.nextObjectiveId + ? getSecondBattleReliefObjective(progress.nextObjectiveId) + : undefined; + if (progress.completed) { + return '현장 수습이 끝났습니다. 남쪽 표식에서 군영으로 돌아가세요.'; + } + return objective + ? `${objective.label} · ${this.reliefActorName(objective.targetActorId)}에게 다가가세요.` + : '간옹에게 수습 기록을 전하고 광종 진군 방침을 정하세요.'; + } + + private reliefActorName(actorId: string) { + return secondReliefActorDefinitions.find((actor) => actor.id === actorId) + ?.name ?? '현장 인물'; + } + private resetRuntimeState() { this.backgroundImage = undefined; this.backgroundFallback = false; @@ -618,7 +840,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; - this.interactionQueued = false; + this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; this.lastNotice = ''; @@ -626,6 +848,17 @@ export class CampVisitExplorationScene extends Phaser.Scene { private visitAvailable() { const campaign = getCampaignState(); + if (this.isSecondReliefVisit()) { + return ( + campaign.step === 'second-camp' && + campaign.latestBattleId === secondBattleReliefSourceBattleId && + campaign.firstBattleReport?.battleId === + secondBattleReliefSourceBattleId && + campaign.firstBattleReport.outcome === 'victory' && + campaign.battleHistory[secondBattleReliefSourceBattleId]?.outcome === + 'victory' + ); + } return ( this.visitId === firstPursuitScoutVisitId && campaign.step === 'first-camp' && @@ -640,10 +873,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { private drawCamp() { this.cameras.main.setBackgroundColor('#1a241e'); - if (this.textures.exists(campBackgroundKey)) { + const backgroundKey = this.currentBackgroundKey(); + if (this.textures.exists(backgroundKey)) { this.backgroundImage = this.add - .image(0, 0, campBackgroundKey) + .image(0, 0, backgroundKey) .setOrigin(0) + .setDisplaySize(sceneWidth, sceneHeight) .setDepth(-120); this.registerCampCollisions(); this.drawCampWayfinding(); @@ -690,26 +925,69 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private registerCampCollisions() { - [ - [622, 178, 325, 125], - [108, 205, 248, 140], - [1188, 212, 214, 132], - [90, 694, 216, 140], - [295, 380, 124, 72], - [1018, 655, 182, 142], - [330, 820, 76, 54], - [413, 832, 62, 43], - [1285, 760, 72, 52], - [728, 520, 104, 64], - [955, 448, 40, 46], - [1080, 448, 40, 46], - [1205, 448, 40, 46] - ].forEach(([x, y, width, height]) => { + const collisionRects = this.isSecondReliefVisit() + ? [ + [42, 108, 230, 340], + [42, 570, 300, 245], + [850, 108, 428, 258], + [1120, 350, 326, 265], + [870, 430, 150, 130], + [300, 690, 170, 170] + ] + : [ + [622, 178, 325, 125], + [108, 205, 248, 140], + [1188, 212, 214, 132], + [90, 694, 216, 140], + [295, 380, 124, 72], + [1018, 655, 182, 142], + [330, 820, 76, 54], + [413, 832, 62, 43], + [1285, 760, 72, 52], + [728, 520, 104, 64], + [955, 448, 40, 46], + [1080, 448, 40, 46], + [1205, 448, 40, 46] + ]; + collisionRects.forEach(([x, y, width, height]) => { this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height)); }); } private drawCampWayfinding() { + if (this.isSecondReliefVisit()) { + [ + [1090, 312, '북쪽 마을'], + [380, 315, '강가 나루'], + [560, 525, '전령의 말발굽'], + [820, 548, '수습 장부'], + [790, 800, '남쪽 · 군영 귀환'] + ].forEach(([x, y, label]) => { + this.add + .text(Number(x), Number(y), String(label), { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '18px', + color: '#f4e3b5', + fontStyle: 'bold', + backgroundColor: '#15120ed4', + padding: { left: 10, right: 10, top: 4, bottom: 4 } + }) + .setOrigin(0.5) + .setDepth(45); + }); + const routeGlow = this.add + .ellipse(742, 636, 610, 330, 0xe0bd6e, 0.035) + .setDepth(34); + this.tweens.add({ + targets: routeGlow, + alpha: { from: 0.02, to: 0.08 }, + duration: 1100, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + return; + } [ [780, 294, '지휘 막사'], [250, 340, '정찰대 막사'], @@ -757,6 +1035,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private drawCampFireGlow() { + if (this.isSecondReliefVisit()) { + return; + } const glow = this.add .ellipse(780, 540, 230, 150, 0xf2a241, 0.08) .setDepth(35); @@ -773,6 +1054,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private drawHud() { + if (this.isSecondReliefVisit()) { + this.drawSecondReliefHud(); + return; + } this.add .text(48, 27, '탁현 의용군 막사 · 첫 승리 뒤', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -923,6 +1208,153 @@ export class CampVisitExplorationScene extends Phaser.Scene { .setVisible(false); } + private drawSecondReliefHud() { + const definition = secondBattleReliefExplorationDefinition; + this.add + .text(48, 27, '탁현 북쪽 마을 · 추격전 뒤', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '34px', + color: '#f4e3b5', + fontStyle: 'bold' + }) + .setDepth(1500); + this.add + .text(620, 35, '전투가 끝난 자리에서 백성과 다음 길을 잇습니다', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#b4c0cb' + }) + .setDepth(1500); + + this.add + .text(1536, 43, '마을·나루 수습', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '30px', + color: '#f4e3b5', + fontStyle: 'bold' + }) + .setDepth(1500); + this.add + .text(1538, 91, definition.subtitle, { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8', + lineSpacing: 4, + wordWrap: { width: 330, useAdvancedWrap: true } + }) + .setDepth(1500); + + this.objectiveCard = this.drawObjectiveCard( + 188, + '순서대로 확인', + '관우 → 사공 → 장비 → 간옹', + '현장의 증언과 흔적을 하나씩 확인합니다.' + ); + this.objectiveStatus = this.add + .text(1550, 207, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.drawObjectiveCard( + 376, + '광종 진군 방침', + '마을 보급선 / 전령 추적', + '세 현장을 확인한 뒤 간옹과 우선 기준을 정합니다.' + ); + this.add + .text(1550, 395, '◆ 전투 준비에 반영', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.drawObjectiveCard( + 564, + '군영으로 복귀', + '남쪽 귀환 표식', + '중간 수습 기록도 저장되어 나중에 이어갈 수 있습니다.' + ); + this.add + .text(1550, 583, '◆ 진행 저장', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#a8d58e', + fontStyle: 'bold' + }) + .setDepth(1502); + + this.progressText = this.add + .text(1538, 750, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#cbd3dc', + lineSpacing: 7, + wordWrap: { width: 330, useAdvancedWrap: true } + }) + .setDepth(1502); + + this.add + .text(46, 980, '이동', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + this.add + .text(46, 1012, 'WASD / 방향키 · 바닥이나 인물 클릭', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }) + .setDepth(1502); + this.add + .text(485, 980, '상호작용', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#d8b15f', + fontStyle: 'bold' + }) + .setDepth(1502); + this.add + .text(485, 1012, '가까이에서 E / Space / Enter', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '19px', + color: '#e3e7eb' + }) + .setDepth(1502); + this.add + .text(1420, 1012, 'ESC · 대화/선택 닫기', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '17px', + color: '#9fabb8' + }) + .setOrigin(1, 0) + .setDepth(1502); + + this.promptBackground = this.add + .rectangle(940, 1018, 610, 58, 0x2a2119, 0.98) + .setStrokeStyle(2, 0xd8b15f, 0.92) + .setDepth(1510) + .setVisible(false); + this.promptText = this.add + .text(940, 1018, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '21px', + color: '#f6e3af', + fontStyle: 'bold' + }) + .setOrigin(0.5) + .setDepth(1511) + .setVisible(false); + } + private drawObjectiveCard( y: number, title: string, @@ -961,7 +1393,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private createActors() { - const start = { x: 780, y: 720 }; + const start = this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.player + : { x: 780, y: 720 }; + const startingDirection = this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.player.direction + : 'north'; + this.playerDirection = startingDirection; this.playerShadow = this.add .ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45) .setDepth(800); @@ -970,7 +1408,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { start.x, start.y, characterDisplaySize + 6, - 'north' + startingDirection ); this.player.setDepth(100 + start.y); this.playerNameplate = this.add @@ -984,7 +1422,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { .setOrigin(0.5) .setDepth(1200); - actorDefinitions.forEach((definition) => { + this.currentActorDefinitions().forEach((definition) => { const shadow = this.add .ellipse( definition.x, @@ -999,7 +1437,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { definition.textureKey, definition.x, definition.y, - definition.id === 'jian-yong' + definition.id === 'jian-yong' || + definition.id === 'jian-yong-records' ? characterDisplaySize + 4 : characterDisplaySize, definition.direction @@ -1050,6 +1489,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); }); + const campExit = this.currentCampExit(); const exitMarker = this.add .text(campExit.x, campExit.y - 58, '↩', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -1062,14 +1502,19 @@ export class CampVisitExplorationScene extends Phaser.Scene { .setOrigin(0.5) .setDepth(1201); this.add - .text(campExit.x, campExit.y + 18, '군영 장부', { + .text( + campExit.x, + campExit.y + 18, + this.isSecondReliefVisit() ? '군영으로 복귀' : '군영 장부', + { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', color: '#f0dfbd', fontStyle: 'bold', backgroundColor: '#17130fcc', padding: { left: 8, right: 8, top: 3, bottom: 3 } - }) + } + ) .setOrigin(0.5) .setDepth(1200); this.tweens.add({ @@ -1163,42 +1608,19 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private setupInput() { - const keyboard = this.input.keyboard; - if (keyboard) { - this.cursorKeys = keyboard.createCursorKeys(); - this.moveKeys = { - up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), - down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), - left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), - right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) - }; - this.interactKeys = [ - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E), - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE), - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER) - ]; - this.interactKeys.forEach((key) => { - key.on('down', () => { - this.interactionQueued = true; - }); - }); - keyboard - .addKey(Phaser.Input.Keyboard.KeyCodes.ESC) - .on('down', () => this.handleEscape()); - keyboard - .addKey(Phaser.Input.Keyboard.KeyCodes.ONE) - .on('down', () => this.chooseScoutByIndex(0)); - keyboard - .addKey(Phaser.Input.Keyboard.KeyCodes.TWO) - .on('down', () => this.chooseScoutByIndex(1)); - keyboard.addCapture([ - Phaser.Input.Keyboard.KeyCodes.UP, - Phaser.Input.Keyboard.KeyCodes.DOWN, - Phaser.Input.Keyboard.KeyCodes.LEFT, - Phaser.Input.Keyboard.KeyCodes.RIGHT, - Phaser.Input.Keyboard.KeyCodes.SPACE - ]); - } + this.explorationInput = new ExplorationInputController(this) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.ESC, + () => this.handleEscape() + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.ONE, + () => this.chooseChoiceByIndex(0) + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.TWO, + () => this.chooseChoiceByIndex(1) + ); this.input.on( Phaser.Input.Events.POINTER_DOWN, @@ -1217,11 +1639,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.advanceDialogue(); return; } + const activeMovementBounds = this.currentMovementBounds(); if ( - pointer.x >= movementBounds.left && - pointer.x <= movementBounds.right && - pointer.y >= movementBounds.top && - pointer.y <= movementBounds.bottom + pointer.x >= activeMovementBounds.left && + pointer.x <= activeMovementBounds.right && + pointer.y >= activeMovementBounds.top && + pointer.y <= activeMovementBounds.bottom ) { this.beginPointerMovement(pointer.x, pointer.y); } @@ -1246,7 +1669,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } this.showPromptMessage( - '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요.' + this.isSecondReliefVisit() + ? '군영으로 돌아가려면 남쪽 귀환 표식까지 직접 이동하세요.' + : '군영 장부로 돌아가려면 남쪽 출구까지 직접 이동하세요.' ); } @@ -1420,16 +1845,17 @@ export class CampVisitExplorationScene extends Phaser.Scene { private nearestWalkablePoint(x: number, y: number) { const margin = playerCollisionRadius + 4; + const activeMovementBounds = this.currentMovementBounds(); const clamped = new Phaser.Math.Vector2( Phaser.Math.Clamp( x, - movementBounds.left + margin, - movementBounds.right - margin + activeMovementBounds.left + margin, + activeMovementBounds.right - margin ), Phaser.Math.Clamp( y, - movementBounds.top + margin, - movementBounds.bottom - margin + activeMovementBounds.top + margin, + activeMovementBounds.bottom - margin ) ); if (this.canPlayerOccupy(clamped.x, clamped.y)) { @@ -1441,13 +1867,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { const candidate = new Phaser.Math.Vector2( Phaser.Math.Clamp( clamped.x + Math.cos(angle) * radius, - movementBounds.left + margin, - movementBounds.right - margin + activeMovementBounds.left + margin, + activeMovementBounds.right - margin ), Phaser.Math.Clamp( clamped.y + Math.sin(angle) * radius, - movementBounds.top + margin, - movementBounds.bottom - margin + activeMovementBounds.top + margin, + activeMovementBounds.bottom - margin ) ); if (this.canPlayerOccupy(candidate.x, candidate.y)) { @@ -1515,6 +1941,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { destination: Phaser.Math.Vector2 ) { const margin = playerCollisionRadius + 4; + const activeMovementBounds = this.currentMovementBounds(); const candidates = [ new Phaser.Math.Vector2(start.x, destination.y), new Phaser.Math.Vector2(destination.x, start.y) @@ -1545,13 +1972,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { candidate.set( Phaser.Math.Clamp( candidate.x, - movementBounds.left + margin, - movementBounds.right - margin + activeMovementBounds.left + margin, + activeMovementBounds.right - margin ), Phaser.Math.Clamp( candidate.y, - movementBounds.top + margin, - movementBounds.bottom - margin + activeMovementBounds.top + margin, + activeMovementBounds.bottom - margin ) ); if (!this.canPlayerOccupy(candidate.x, candidate.y)) { @@ -1632,29 +2059,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private consumeInteractionRequest() { - const keyPressedThisFrame = this.interactKeys.some((key) => - Phaser.Input.Keyboard.JustDown(key) - ); - const requested = this.interactionQueued || keyPressedThisFrame; - this.interactionQueued = false; - return requested; + return this.explorationInput?.consumeInteractionRequest() ?? false; } private keyboardMovementDirection() { - const vector = new Phaser.Math.Vector2(); - if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) { - vector.x -= 1; - } - if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) { - vector.x += 1; - } - if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) { - vector.y -= 1; - } - if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) { - vector.y += 1; - } - return vector; + return ( + this.explorationInput?.movementDirection() ?? + new Phaser.Math.Vector2() + ); } private directionForVector( @@ -1667,11 +2079,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private canPlayerOccupy(x: number, y: number) { + const activeMovementBounds = this.currentMovementBounds(); if ( - x - playerCollisionRadius < movementBounds.left || - x + playerCollisionRadius > movementBounds.right || - y - playerCollisionRadius < movementBounds.top || - y + playerCollisionRadius > movementBounds.bottom + x - playerCollisionRadius < activeMovementBounds.left || + x + playerCollisionRadius > activeMovementBounds.right || + y - playerCollisionRadius < activeMovementBounds.top || + y + playerCollisionRadius > activeMovementBounds.bottom ) { return false; } @@ -1737,17 +2150,18 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private safeApproachPosition(targetX: number, targetY: number) { + const activeMovementBounds = this.currentMovementBounds(); return ( this.approachPositions(targetX, targetY)[0] ?? { x: Phaser.Math.Clamp( targetX, - movementBounds.left + 40, - movementBounds.right - 40 + activeMovementBounds.left + 40, + activeMovementBounds.right - 40 ), y: Phaser.Math.Clamp( targetY + 100, - movementBounds.top + 40, - movementBounds.bottom - 40 + activeMovementBounds.top + 40, + activeMovementBounds.bottom - 40 ) } ); @@ -1773,6 +2187,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } this.faceCharactersForConversation(view); + if (this.isSecondReliefVisit()) { + this.interactWithReliefActor(view); + return; + } if (view.definition.kind === 'objective') { this.interactWithJianYong(view); return; @@ -1787,6 +2205,130 @@ export class CampVisitExplorationScene extends Phaser.Scene { ); } + private interactWithReliefActor(view: ExplorationActorView) { + const objective = view.definition.objectiveId + ? getSecondBattleReliefObjective(view.definition.objectiveId) + : undefined; + if (!objective) { + this.startDialogue( + [...view.definition.dialogue], + () => this.restoreActorDirection(view), + view.definition.id + ); + return; + } + + const campaign = getCampaignState(); + const progress = secondBattleReliefProgress(campaign); + if ( + progress.completed || + progress.completedObjectiveIds.includes(objective.id) + ) { + this.startDialogue( + [...(view.definition.repeatDialogue ?? view.definition.dialogue)], + () => this.restoreActorDirection(view), + view.definition.id + ); + return; + } + + const missingPrerequisiteId = objective.prerequisiteObjectiveIds.find( + (objectiveId) => + !progress.completedObjectiveIds.includes(objectiveId) + ); + if (missingPrerequisiteId) { + const missingPrerequisite = getSecondBattleReliefObjective( + missingPrerequisiteId + ); + this.startDialogue( + [ + { + speaker: '현장 기록', + text: missingPrerequisite + ? `아직 '${missingPrerequisite.label}' 확인이 남았습니다. 앞선 현장부터 살핀 뒤 다시 오십시오.` + : '앞선 현장 확인이 남았습니다.' + } + ], + () => this.restoreActorDirection(view), + view.definition.id + ); + return; + } + + if (objective.id === 'set-guangzong-priority') { + this.startDialogue( + [...view.definition.dialogue], + () => { + this.restoreActorDirection(view); + this.openChoicePanel(); + }, + view.definition.id + ); + return; + } + + const dialogueLines = + objective.id === 'reassure-northern-village' + ? this.reliefOpeningDialogue(view) + : [...view.definition.dialogue]; + this.startDialogue( + dialogueLines, + () => { + this.restoreActorDirection(view); + this.completeReliefObjective(objective.id); + }, + view.definition.id + ); + } + + private reliefOpeningDialogue(view: ExplorationActorView) { + const context = resolveSecondBattleReliefContext( + getCampaignState() + ); + if (!context) { + return [...view.definition.dialogue]; + } + return [ + ...secondBattleReliefExplorationDefinition.introLines, + { + speaker: '전투 뒤 기록', + text: context.arrivalLine + }, + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: context.scoutReview.line + }, + ...view.definition.dialogue + ]; + } + + private completeReliefObjective( + objectiveId: SecondBattleReliefObjectiveId + ) { + const result = recordSecondBattleReliefObjective(objectiveId); + if (!result.ok) { + const message = + result.reason === 'prerequisites-incomplete' + ? '앞선 현장부터 확인해야 합니다.' + : result.reason === 'already-completed' + ? '현장 수습 방침은 이미 기록했습니다.' + : '현장 기록을 저장하지 못했습니다.'; + this.showCompletionToast(message, 'error'); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + return; + } + + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.lastNotice = result.objective.completionLine; + soundDirector.playObjectiveAchieved(); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.refreshInteractionPrompt(); + this.showCompletionToast(result.objective.completionLine, 'success', 1900); + } + private interactWithJianYong(view: ExplorationActorView) { const campaign = getCampaignState(); const choiceId = @@ -1889,22 +2431,30 @@ export class CampVisitExplorationScene extends Phaser.Scene { return; } const line = dialogue.lines[dialogue.lineIndex]; - this.dialogueNameText?.setText(line.speaker); - this.dialogueBodyText?.setText(line.text); this.dialogueProgressText?.setText( `${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속` ); const portraitEntry = this.dialoguePortraitEntry(line); - if ( + const portraitVisible = Boolean( this.dialoguePortrait && portraitEntry && this.textures.exists(portraitEntry.textureKey) - ) { + ); + const textX = portraitVisible ? 342 : 118; + const bodyWidth = portraitVisible ? 1360 : 1640; + this.dialogueNameText + ?.setX(textX) + .setText(line.speaker); + this.dialogueBodyText + ?.setX(textX) + .setWordWrapWidth(bodyWidth, true) + .setText(line.text); + if (portraitVisible && portraitEntry && this.dialoguePortrait) { this.dialoguePortraitFrame?.setVisible(true); this.dialoguePortrait .setTexture(portraitEntry.textureKey) - .setDisplaySize(220, 220) .setVisible(true); + this.fitDialogueFacePortrait(this.dialoguePortrait); return; } this.dialoguePortraitFrame?.setVisible(false); @@ -1915,12 +2465,30 @@ export class CampVisitExplorationScene extends Phaser.Scene { return line.portraitKey ? portraitAssetEntryForStoryContext( line.portraitKey, - portraitContext, - `first-pursuit-dialogue-${line.speaker}` + this.currentPortraitContext(), + `${this.visitId}-dialogue-${line.speaker}` ) : undefined; } + private fitDialogueFacePortrait(portrait: Phaser.GameObjects.Image) { + const sourceWidth = portrait.frame.realWidth; + const sourceHeight = portrait.frame.realHeight; + const cropSize = Math.round( + Math.min(sourceWidth, sourceHeight) * 0.6 + ); + const cropX = Math.round((sourceWidth - cropSize) / 2); + const cropY = Math.round( + Math.min( + Math.max(0, sourceHeight * 0.035), + sourceHeight - cropSize + ) + ); + portrait + .setCrop(cropX, cropY, cropSize, cropSize) + .setScale(220 / cropSize); + } + private advanceDialogue() { const dialogue = this.dialogueState; if (!dialogue || this.navigationPending) { @@ -1984,7 +2552,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { const title = this.add.text( panelBounds.x + 40, panelBounds.y + 38, - firstPursuitScoutVisitDefinition.title, + this.isSecondReliefVisit() + ? secondBattleReliefExplorationDefinition.title + : firstPursuitScoutVisitDefinition.title, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '31px', @@ -1995,7 +2565,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { const hint = this.add.text( panelBounds.x + 40, panelBounds.y + 84, - '먼저 확인할 길을 선택하세요. 결정과 보상은 저장되며 두 번째 전투의 배치 정보로 이어집니다.', + this.isSecondReliefVisit() + ? '광종 구원로에서 먼저 세울 기준을 선택하세요. 결정과 보상은 저장되며 세 번째 전투의 출진 준비로 이어집니다.' + : '먼저 확인할 길을 선택하세요. 결정과 보상은 저장되며 두 번째 전투의 배치 정보로 이어집니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '19px', @@ -2008,7 +2580,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { title, hint ]; - this.choiceViews = firstPursuitScoutVisitDefinition.choices.map( + this.choiceViews = this.currentChoices().map( (choice, index) => { const x = panelBounds.x + 40 + index * 820; const y = panelBounds.y + 140; @@ -2033,7 +2605,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); const reward = this.add.text( x + 76, - y + 91, + y + 84, this.choiceRewardText(choice), { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -2042,14 +2614,25 @@ export class CampVisitExplorationScene extends Phaser.Scene { fontStyle: 'bold' } ); + const effect = this.add.text( + x + 76, + y + 120, + this.choiceEffectText(choice), + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '16px', + color: '#c2ccd7', + wordWrap: { width: 670, useAdvancedWrap: true } + } + ); button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99) ); button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98) ); - button.on('pointerdown', () => this.chooseScout(choice)); - objects.push(button, number, label, reward); + button.on('pointerdown', () => this.chooseVisitChoice(choice)); + objects.push(button, number, label, reward, effect); return { choice, button }; } ); @@ -2073,7 +2656,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.choiceReadyAt = this.time.now + inputCarryoverGuardMs; } - private chooseScoutByIndex(index: number) { + private chooseChoiceByIndex(index: number) { if ( !this.choicePanel || this.navigationPending || @@ -2081,9 +2664,23 @@ export class CampVisitExplorationScene extends Phaser.Scene { ) { return; } - const choice = firstPursuitScoutVisitDefinition.choices[index]; + const choice = this.currentChoices()[index]; if (choice) { - this.chooseScout(choice); + this.chooseVisitChoice(choice); + } + } + + private chooseVisitChoice(choice: VisitChoice) { + if (this.isSecondReliefVisit()) { + const reliefChoice = getSecondBattleReliefChoice(choice.id); + if (reliefChoice) { + this.chooseReliefPriority(reliefChoice); + } + return; + } + const scoutChoice = getFirstPursuitScoutVisitChoice(choice.id); + if (scoutChoice) { + this.chooseScout(scoutChoice); } } @@ -2138,6 +2735,59 @@ export class CampVisitExplorationScene extends Phaser.Scene { ); } + private chooseReliefPriority(choice: SecondBattleReliefChoice) { + if ( + this.navigationPending || + (this.choicePanel && this.time.now < this.choiceReadyAt) + ) { + return; + } + const result = completeSecondBattleReliefExploration(choice.id); + if (!result.ok) { + this.closeChoicePanel(); + const message = + result.reason === 'already-completed' + ? '현장 수습 방침은 이미 기록했습니다.' + : result.reason === 'prerequisites-incomplete' + ? '마을과 나루의 현장 확인을 먼저 마쳐야 합니다.' + : result.reason === 'invalid-choice' + ? '선택할 수 없는 광종 진군 방침입니다.' + : '현장 수습 기록을 저장하지 못했습니다.'; + this.showCompletionToast(message, 'error'); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + return; + } + + this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.closeChoicePanel(); + this.lastNotice = `수습 완료 · ${result.choice.label}`; + soundDirector.playEffect('bond-resonance', { + volume: 0.28, + stopAfterMs: 1100 + }); + this.refreshObjectiveHud(); + this.refreshActorMarkers(); + this.startDialogue( + [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: result.choice.response + }, + { + speaker: '현장 수습 기록', + text: `${result.choice.label} · 공명 +${result.choice.bondExp} · ${result.choice.itemRewards.join(' · ')}` + } + ], + () => + this.showCompletionToast( + '수습 방침이 광종 구원로의 출진 준비에 반영됩니다.' + ), + 'jian-yong-records' + ); + } + private closeChoicePanel() { this.choicePanel?.destroy(); this.choicePanel = undefined; @@ -2147,12 +2797,51 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.refreshInteractionPrompt(); } - private choiceRewardText(choice: FirstPursuitScoutVisitChoice) { + private choiceRewardText(choice: VisitChoice) { return `공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}`; } + private choiceEffectText(choice: VisitChoice) { + if (this.isSecondReliefVisit()) { + const reliefChoice = getSecondBattleReliefChoice(choice.id); + if (reliefChoice?.thirdBattleEffect.kind === 'village-supply-line') { + return '다음 전투 · 유비 후방 지원 · 휴대 콩 +1'; + } + if (reliefChoice?.thirdBattleEffect.kind === 'ferry-messenger-intel') { + return '다음 전투 · 장비 측면 추천 · 전령 마원의 첫 행동 표시'; + } + } + return choice.id === 'trace-river-ambush' + ? '다음 전투 · 나루 앞 숲과 궁병 엄호선을 먼저 확인' + : '다음 전투 · 마을 진입로와 피난민 퇴로를 먼저 확인'; + } + private interactWithExit() { if (!this.visitComplete()) { + if (this.isSecondReliefVisit()) { + const progress = secondBattleReliefProgress(); + this.startDialogue( + [ + { + speaker: '유비', + portraitKey: 'liu-bei-yellow-turban', + text: + progress.completedObjectiveIds.length > 0 + ? '확인한 현장은 장부에 남겼다. 잠시 군영으로 돌아가도 다음에는 이어서 수습할 수 있다.' + : '마을과 나루를 아직 살피지 못했다. 이번에는 군영으로 돌아가되 출진 전 다시 와야 한다.' + }, + { + speaker: '현장 기록', + text: progress.choiceReady + ? '현장 확인 완료 · 광종 진군 방침 결정 대기 · 진행 내용은 저장됩니다.' + : `현장 확인 ${Math.min(3, progress.completedObjectiveIds.length)} / 3 · 진행 내용은 저장됩니다.` + } + ], + () => this.returnToCamp(), + 'camp-exit' + ); + return; + } this.startDialogue( [ { @@ -2206,7 +2895,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { .text( sceneWidth / 2, sceneHeight / 2 - 32, - '탁현 의용군 막사 기록', + this.isSecondReliefVisit() + ? '북쪽 마을·나루 수습 기록' + : '탁현 의용군 막사 기록', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '44px', @@ -2220,8 +2911,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { sceneWidth / 2, sceneHeight / 2 + 44, this.visitComplete() - ? '간옹의 정찰 기록을 출진 장부에 올립니다.' - : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', + ? this.isSecondReliefVisit() + ? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.' + : '간옹의 정찰 기록을 출진 장부에 올립니다.' + : this.isSecondReliefVisit() + ? '확인한 현장 기록을 저장하고 군영으로 돌아갑니다.' + : '정찰 활동을 남겨 두고 군영 장부로 돌아갑니다.', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '24px', @@ -2244,6 +2939,51 @@ export class CampVisitExplorationScene extends Phaser.Scene { private refreshObjectiveHud() { const campaign = getCampaignState(); const completed = this.visitComplete(campaign); + if (this.isSecondReliefVisit()) { + const progress = secondBattleReliefProgress(campaign); + const choiceId = + campaign.campVisitChoiceIds[secondBattleReliefVisitId]; + const choice = choiceId + ? getSecondBattleReliefChoice(choiceId) + : undefined; + const completedFieldCount = Math.min( + 3, + progress.completedObjectiveIds.length + ); + const nextObjective = progress.nextObjectiveId + ? getSecondBattleReliefObjective(progress.nextObjectiveId) + : undefined; + this.objectiveStatus + ?.setText( + completed + ? '✓ 수습 기록 완료' + : progress.choiceReady + ? '◆ 현장 확인 완료 · 방침 결정 대기' + : `◆ 현장 확인 ${completedFieldCount} / 3` + ) + .setColor(completed ? '#a8d58e' : '#e1bc69'); + this.objectiveCard?.setStrokeStyle( + 2, + completed ? 0x628653 : 0xd8b15f, + completed ? 0.9 : 0.76 + ); + this.progressText?.setText( + completed + ? `현지 수습 완료\n${choice?.label ?? secondBattleReliefExplorationDefinition.title}\n\n선택한 방침은 광종 구원로의 출진 준비와 전투 정보에 이어집니다.` + : `${ + progress.choiceReady + ? '현장 확인 완료 · 방침 결정 대기' + : `현장 확인 ${completedFieldCount} / 3` + }\n\n${ + progress.choiceReady + ? '간옹에게 기록을 전하고 광종 진군 방침을 정하세요.' + : nextObjective + ? `다음 · ${nextObjective.label}\n${nextObjective.detail}` + : '간옹에게 기록을 전하고 광종 진군 방침을 정하세요.' + }` + ); + return; + } const choiceId = campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; const choice = choiceId @@ -2266,14 +3006,48 @@ export class CampVisitExplorationScene extends Phaser.Scene { private refreshActorMarkers() { const completed = this.visitComplete(); + const reliefProgress = this.isSecondReliefVisit() + ? secondBattleReliefProgress() + : undefined; this.actorViews.forEach(({ definition, marker }) => { if (definition.kind !== 'objective') { marker.setText('…'); return; } - marker.setText(completed ? '✓' : '!'); - marker.setColor(completed ? '#e5f2d5' : '#2a2013'); - marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68'); + const objectiveComplete = + completed || + Boolean( + definition.objectiveId && + reliefProgress?.completedObjectiveIds.includes( + definition.objectiveId + ) + ); + const objectiveCurrent = + !completed && + Boolean( + definition.objectiveId === reliefProgress?.nextObjectiveId + ); + marker.setText( + objectiveComplete + ? '✓' + : objectiveCurrent || !this.isSecondReliefVisit() + ? '!' + : '·' + ); + marker.setColor( + objectiveComplete + ? '#e5f2d5' + : objectiveCurrent || !this.isSecondReliefVisit() + ? '#2a2013' + : '#d7dde4' + ); + marker.setBackgroundColor( + objectiveComplete + ? '#4e7549' + : objectiveCurrent || !this.isSecondReliefVisit() + ? '#e5bd68' + : '#56616e' + ); }); } @@ -2372,19 +3146,33 @@ export class CampVisitExplorationScene extends Phaser.Scene { target, distance: this.distanceTo(target.x, target.y) })) - .filter(({ distance }) => distance <= radius) + .filter( + ({ target, distance }) => + distance <= + (this.isSecondReliefVisit() && target.kind === 'exit' + ? Math.min(radius, interactionRadius + 8) + : radius) + ) .sort((left, right) => left.distance - right.distance)[0]?.target; } private allInteractionTargets(): InteractionTarget[] { + const campExit = this.currentCampExit(); + const reliefProgress = this.isSecondReliefVisit() + ? secondBattleReliefProgress() + : undefined; return [ ...Array.from(this.actorViews.values()).map( ({ definition, sprite }) => ({ kind: 'actor' as const, id: definition.id, name: - definition.kind === 'objective' - ? `${definition.name}과 정찰로 상의` + this.isSecondReliefVisit() + ? definition.objectiveId === reliefProgress?.nextObjectiveId + ? `${definition.name} · 다음 현장 확인` + : `${definition.name}와 대화` + : definition.kind === 'objective' + ? `${definition.name}과 정찰로 상의` : `${definition.name}와 대화`, x: sprite.x, y: sprite.y @@ -2405,7 +3193,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { private visitComplete(campaign = getCampaignState()) { return campaign.completedCampVisits.includes( - firstPursuitScoutVisitId + this.visitId ); } diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 7ff8ce1..6d76419 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -27,6 +27,7 @@ import { releaseUnitBaseSheetTextures, type UnitDirection } from '../data/unitAssets'; +import { ExplorationInputController } from '../input/ExplorationInputController'; import { chooseCityStayResonance, collectCityStayInformation, @@ -137,19 +138,11 @@ export class CityStayScene extends Phaser.Scene { private loadingOverlay?: Phaser.GameObjects.Container; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; - private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys; - private moveKeys?: { - up: Phaser.Input.Keyboard.Key; - down: Phaser.Input.Keyboard.Key; - left: Phaser.Input.Keyboard.Key; - right: Phaser.Input.Keyboard.Key; - }; - private interactKeys: Phaser.Input.Keyboard.Key[] = []; + private explorationInput?: ExplorationInputController; private moveTarget?: Phaser.Math.Vector2; private ready = false; private navigationPending = false; private inputReadyAt = Number.POSITIVE_INFINITY; - private interactionQueued = false; private stepIndex = 0; private lastStepAt = 0; private lastNotice = ''; @@ -191,6 +184,8 @@ export class CityStayScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.moveTarget = undefined; + this.explorationInput?.destroy(); + this.explorationInput = undefined; this.dialogueState = undefined; releaseUnitBaseSheetTextures(this); }); @@ -215,13 +210,13 @@ export class CityStayScene extends Phaser.Scene { update(time: number, delta: number) { if (!this.ready || this.navigationPending || time < this.inputReadyAt) { - this.interactionQueued = false; + this.explorationInput?.discardInteractionRequest(); return; } if (this.shopPanel || this.choicePanel) { this.stopPlayerMovement(); - this.interactionQueued = false; + this.explorationInput?.discardInteractionRequest(); return; } @@ -438,7 +433,7 @@ export class CityStayScene extends Phaser.Scene { this.ready = false; this.navigationPending = false; this.inputReadyAt = Number.POSITIVE_INFINITY; - this.interactionQueued = false; + this.explorationInput = undefined; this.stepIndex = 0; this.lastStepAt = 0; this.lastNotice = ''; @@ -934,36 +929,19 @@ export class CityStayScene extends Phaser.Scene { } private setupInput() { - const keyboard = this.input.keyboard; - if (keyboard) { - this.cursorKeys = keyboard.createCursorKeys(); - this.moveKeys = { - up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W), - down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S), - left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A), - right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D) - }; - this.interactKeys = [ - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E), - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE), - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER) - ]; - this.interactKeys.forEach((key) => { - key.on('down', () => { - this.interactionQueued = true; - }); - }); - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC).on('down', () => this.handleEscape()); - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE).on('down', () => this.chooseDialogueByIndex(0)); - keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO).on('down', () => this.chooseDialogueByIndex(1)); - keyboard.addCapture([ - Phaser.Input.Keyboard.KeyCodes.UP, - Phaser.Input.Keyboard.KeyCodes.DOWN, - Phaser.Input.Keyboard.KeyCodes.LEFT, - Phaser.Input.Keyboard.KeyCodes.RIGHT, - Phaser.Input.Keyboard.KeyCodes.SPACE - ]); - } + this.explorationInput = new ExplorationInputController(this) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.ESC, + () => this.handleEscape() + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.ONE, + () => this.chooseDialogueByIndex(0) + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.TWO, + () => this.chooseDialogueByIndex(1) + ); this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { @@ -1065,27 +1043,14 @@ export class CityStayScene extends Phaser.Scene { } private consumeInteractionRequest() { - const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key)); - const requested = this.interactionQueued || keyPressedThisFrame; - this.interactionQueued = false; - return requested; + return this.explorationInput?.consumeInteractionRequest() ?? false; } private keyboardMovementDirection() { - const vector = new Phaser.Math.Vector2(); - if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) { - vector.x -= 1; - } - if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) { - vector.x += 1; - } - if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) { - vector.y -= 1; - } - if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) { - vector.y += 1; - } - return vector; + return ( + this.explorationInput?.movementDirection() ?? + new Phaser.Math.Vector2() + ); } private directionForVector(vector: Phaser.Math.Vector2): UnitDirection { diff --git a/src/game/state/secondBattleReliefActions.ts b/src/game/state/secondBattleReliefActions.ts new file mode 100644 index 0000000..91839f8 --- /dev/null +++ b/src/game/state/secondBattleReliefActions.ts @@ -0,0 +1,258 @@ +import { + completedSecondBattleReliefObjectiveIds, + getSecondBattleReliefChoice, + getSecondBattleReliefObjective, + secondBattleReliefObjectiveIds, + secondBattleReliefObjectiveProgressId, + secondBattleReliefPrerequisiteObjectiveIds, + secondBattleReliefSourceBattleId, + secondBattleReliefTargetBattleId, + secondBattleReliefVisitId, + type SecondBattleReliefChoice, + type SecondBattleReliefObjective, + type SecondBattleReliefObjectiveId +} from '../data/secondBattleReliefExploration'; +import { + resolveSecondBattleReliefMemory, + type SecondBattleReliefMemory +} from '../data/secondBattleReliefMemory'; +import { + applyCampVisitReward, + getCampaignState, + setCampaignState, + type CampaignState +} from './campaignState'; + +type SecondBattleReliefFailure = + | 'invalid-campaign' + | 'invalid-objective' + | 'prerequisites-incomplete' + | 'choice-required' + | 'invalid-choice' + | 'already-completed' + | 'save-unavailable'; + +export type SecondBattleReliefObjectiveActionResult = + | { + ok: true; + objective: SecondBattleReliefObjective; + completedObjectiveIds: SecondBattleReliefObjectiveId[]; + campaign: CampaignState; + } + | { + ok: false; + reason: SecondBattleReliefFailure; + }; + +export type SecondBattleReliefCompletionResult = + | { + ok: true; + choice: SecondBattleReliefChoice; + memory: SecondBattleReliefMemory; + campaign: CampaignState; + } + | { + ok: false; + reason: SecondBattleReliefFailure; + }; + +export function secondBattleReliefProgress( + campaign: CampaignState = getCampaignState() +) { + const completedObjectiveIds = + completedSecondBattleReliefObjectiveIds( + campaign.completedCampVisits + ); + const completed = new Set(completedObjectiveIds); + const visitCompleted = campaign.completedCampVisits.includes( + secondBattleReliefVisitId + ); + const nextObjective = visitCompleted + ? undefined + : secondBattleReliefObjectiveIds + .map((objectiveId) => getSecondBattleReliefObjective(objectiveId)) + .find((objective) => + objective && + !completed.has(objective.id) && + objective.prerequisiteObjectiveIds.every((objectiveId) => + completed.has(objectiveId) + ) + ); + + return { + visitId: secondBattleReliefVisitId, + completed: visitCompleted, + completedObjectiveIds, + nextObjectiveId: nextObjective?.id ?? null, + choiceReady: + !visitCompleted && + secondBattleReliefPrerequisiteObjectiveIds.every((objectiveId) => + completed.has(objectiveId) + ) + }; +} + +export function recordSecondBattleReliefObjective( + objectiveId: string +): SecondBattleReliefObjectiveActionResult { + let campaign: CampaignState; + try { + campaign = getCampaignState(); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsSecondBattleRelief(campaign)) { + return { ok: false, reason: 'invalid-campaign' }; + } + if ( + campaign.completedCampVisits.includes(secondBattleReliefVisitId) + ) { + return { ok: false, reason: 'already-completed' }; + } + + const objective = getSecondBattleReliefObjective(objectiveId); + if (!objective) { + return { ok: false, reason: 'invalid-objective' }; + } + if (objective.id === 'set-guangzong-priority') { + return { ok: false, reason: 'choice-required' }; + } + + const progress = secondBattleReliefProgress(campaign); + if (progress.completedObjectiveIds.includes(objective.id)) { + return { + ok: true, + objective, + completedObjectiveIds: progress.completedObjectiveIds, + campaign + }; + } + if ( + !objective.prerequisiteObjectiveIds.every((prerequisiteId) => + progress.completedObjectiveIds.includes(prerequisiteId) + ) + ) { + return { ok: false, reason: 'prerequisites-incomplete' }; + } + + const updated = persistReliefProgress( + campaign, + secondBattleReliefObjectiveProgressId(objective.id), + {} + ); + if (!updated) { + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + objective, + completedObjectiveIds: + completedSecondBattleReliefObjectiveIds( + updated.completedCampVisits + ), + campaign: updated + }; +} + +export function completeSecondBattleReliefExploration( + choiceId: string +): SecondBattleReliefCompletionResult { + let campaign: CampaignState; + try { + campaign = getCampaignState(); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsSecondBattleRelief(campaign)) { + return { ok: false, reason: 'invalid-campaign' }; + } + if ( + campaign.completedCampVisits.includes(secondBattleReliefVisitId) + ) { + return { ok: false, reason: 'already-completed' }; + } + + const choice = getSecondBattleReliefChoice(choiceId); + if (!choice) { + return { ok: false, reason: 'invalid-choice' }; + } + const progress = secondBattleReliefProgress(campaign); + if (!progress.choiceReady) { + return { ok: false, reason: 'prerequisites-incomplete' }; + } + + const updated = persistReliefProgress( + campaign, + secondBattleReliefVisitId, + { + bondId: choice.bondId, + bondExp: choice.bondExp, + itemRewards: [...choice.itemRewards] + }, + choice.id + ); + if (!updated) { + return { ok: false, reason: 'save-unavailable' }; + } + + const memory = resolveSecondBattleReliefMemory({ + campaign: updated, + battleId: secondBattleReliefTargetBattleId + }); + if (!memory) { + restoreCampaignSnapshot(campaign); + return { ok: false, reason: 'save-unavailable' }; + } + + return { + ok: true, + choice, + memory, + campaign: updated + }; +} + +function campaignSupportsSecondBattleRelief(campaign: CampaignState) { + const settlement = + campaign.battleHistory[secondBattleReliefSourceBattleId]; + const report = campaign.firstBattleReport; + return ( + campaign.step === 'second-camp' && + campaign.latestBattleId === secondBattleReliefSourceBattleId && + settlement?.battleId === secondBattleReliefSourceBattleId && + settlement.outcome === 'victory' && + report?.battleId === secondBattleReliefSourceBattleId && + report.outcome === 'victory' + ); +} + +function persistReliefProgress( + snapshot: CampaignState, + visitId: string, + reward: { + bondId?: string; + bondExp?: number; + itemRewards?: string[]; + }, + choiceId?: string +) { + try { + return applyCampVisitReward( + visitId, + reward, + choiceId + ); + } catch { + restoreCampaignSnapshot(snapshot); + return undefined; + } +} + +function restoreCampaignSnapshot(snapshot: CampaignState) { + try { + setCampaignState(snapshot); + } catch { + // setCampaignState restores the in-memory snapshot before persistence. + } +}