feat: refine exploration presentation and audio flow

This commit is contained in:
2026-07-28 05:00:24 +09:00
parent 1773bffbf2
commit 495f98372c
20 changed files with 2295 additions and 354 deletions

View File

@@ -35,6 +35,17 @@ type LoopChannelTransition = {
};
};
type LoopChannelVolumeRamp = {
revision: number;
completedRevision: number;
active: boolean;
last?: {
fromVolume: number;
toVolume: number;
durationMs: number;
};
};
type LoopChannel = {
name: LoopChannelName;
tracks: AudioTrackMap;
@@ -50,8 +61,11 @@ type LoopChannel = {
elementBaseVolumes: Map<HTMLAudioElement, number>;
elementTrackKeys: Map<HTMLAudioElement, string>;
fadeTimer?: number;
transitionTargetVolume?: number;
volumeRampTimer?: number;
elementRevision: number;
transition: LoopChannelTransition;
volumeRamp: LoopChannelVolumeRamp;
};
export type PlayEffectOptions = {
@@ -135,6 +149,17 @@ type EffectPlaybackPolicy = {
excludedTrackKeys?: ReadonlySet<string>;
};
const loopTargetVolumeRampMs = 400;
const movementStepRatePattern = [0.985, 1.015, 0.995, 1.025, 0.975] as const;
const movementStepGainPattern = [0.96, 1.03, 0.99, 1.04, 0.98] as const;
const pooledCombatRatePattern = [0.99, 1.015, 1, 0.985, 1.01] as const;
const pooledCombatEffectKeys = new Set([
'melee-swing',
'melee-impact',
'arrow-impact',
'arrow-miss'
]);
export class SoundDirector {
private context?: AudioContext;
private master?: GainNode;
@@ -144,6 +169,7 @@ export class SoundDirector {
private effectTracks: AudioTrackMap = {};
private effectPools: Record<string, string[]> = {};
private readonly lastEffectPoolSelections = new Map<string, string>();
private readonly pooledCombatEffectPlayCounts = new Map<string, number>();
private readonly activeEffects = new Set<HTMLAudioElement>();
private readonly effectBaseVolumes = new Map<HTMLAudioElement, number>();
private readonly effectTrackKeys = new Map<HTMLAudioElement, string>();
@@ -208,6 +234,7 @@ export class SoundDirector {
])
);
this.lastEffectPoolSelections.clear();
this.pooledCombatEffectPlayCounts.clear();
}
start() {
@@ -398,8 +425,15 @@ export class SoundDirector {
const { terrain, ...effectOptions } = options;
const surface = movementSurfaceForTerrain(terrain);
const key = movementEffectPoolsBySurface[surface][mounted ? 'mounted' : 'foot'];
const variationIndex = this.positivePatternIndex(stepIndex, movementStepRatePattern.length);
const rateMultiplier = movementStepRatePattern[variationIndex];
const gainMultiplier = movementStepGainPattern[variationIndex];
const requestedVolume = effectOptions.volume ?? this.effectVolume;
const requestedRate = effectOptions.rate ?? 1;
const movementEffectOptions = {
...effectOptions,
volume: requestedVolume * gainMultiplier,
rate: requestedRate * rateMultiplier,
stopAfterMs: effectOptions.stopAfterMs === undefined
? undefined
: Math.max(effectOptions.stopAfterMs, mounted ? 540 : 560)
@@ -413,10 +447,10 @@ export class SoundDirector {
terrain: terrain ?? null,
surface,
at: performance.now(),
volume: effectOptions.volume,
rate: effectOptions.rate
volume: movementEffectOptions.volume,
rate: movementEffectOptions.rate
};
this.playStepPulse(mounted, stepIndex, options.volume);
this.playStepPulse(mounted, stepIndex, movementEffectOptions.volume);
return played;
}
@@ -623,9 +657,10 @@ export class SoundDirector {
const baseVolume = this.normalizeUnitInterval(options.volume ?? this.effectVolume, this.effectVolume);
const trackGain = this.effectTrackGain(resolvedTrack.trackKey);
const compensatedBaseVolume = this.normalizeUnitInterval(baseVolume * trackGain, baseVolume);
const rateMultiplier = this.pooledCombatEffectRateMultiplier(resolvedTrack.poolKey);
effect.preload = 'auto';
effect.volume = this.effectOutputVolume(compensatedBaseVolume);
effect.playbackRate = options.rate ?? 1;
effect.playbackRate = (options.rate ?? 1) * rateMultiplier;
effect.muted = this.muted;
this.activeEffects.add(effect);
this.effectBaseVolumes.set(effect, compensatedBaseVolume);
@@ -872,6 +907,11 @@ export class SoundDirector {
revision: 0,
completedRevision: 0,
active: false
},
volumeRamp: {
revision: 0,
completedRevision: 0,
active: false
}
};
}
@@ -913,7 +953,11 @@ export class SoundDirector {
if (channel.current) {
channel.current.muted = this.muted;
if (!channel.transition.active) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
this.beginLoopVolumeRamp(
channel,
channel.current,
channel.targetVolume
);
}
void channel.current.play().catch(() => undefined);
}
@@ -932,7 +976,10 @@ export class SoundDirector {
private beginLoopTransition(channel: LoopChannel, key?: string, src?: string) {
this.cancelLoopTransitionTimer(channel);
this.cancelLoopVolumeRampTimer(channel);
channel.volumeRamp.active = false;
this.retireOutgoingElements(channel);
channel.transitionTargetVolume = channel.targetVolume;
const previous = channel.current;
const fromKey = channel.currentKey ?? null;
@@ -974,7 +1021,11 @@ export class SoundDirector {
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
if (channel.current) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume * progress);
this.setLoopElementBaseVolume(
channel,
channel.current,
(channel.transitionTargetVolume ?? channel.targetVolume) * progress
);
}
channel.outgoing.forEach((element) => {
const startVolume = channel.outgoingStartVolumes.get(element) ?? channel.elementBaseVolumes.get(element) ?? 0;
@@ -995,10 +1046,22 @@ export class SoundDirector {
this.cancelLoopTransitionTimer(channel);
this.retireOutgoingElements(channel);
if (channel.current) {
this.setLoopElementBaseVolume(channel, channel.current, channel.targetVolume);
this.setLoopElementBaseVolume(
channel,
channel.current,
channel.transitionTargetVolume ?? channel.targetVolume
);
}
channel.transitionTargetVolume = undefined;
channel.transition.active = false;
channel.transition.completedRevision = transitionRevision;
if (channel.current) {
this.beginLoopVolumeRamp(
channel,
channel.current,
channel.targetVolume
);
}
}
private cancelLoopTransitionTimer(channel: LoopChannel) {
@@ -1008,6 +1071,99 @@ export class SoundDirector {
}
}
private beginLoopVolumeRamp(
channel: LoopChannel,
element: HTMLAudioElement,
targetVolume: number
) {
if (
channel.volumeRamp.active &&
channel.volumeRamp.last &&
Math.abs(channel.volumeRamp.last.toVolume - targetVolume) < 0.0001
) {
return;
}
const fromVolume =
channel.elementBaseVolumes.get(element) ?? targetVolume;
if (Math.abs(fromVolume - targetVolume) < 0.0001) {
this.cancelLoopVolumeRampTimer(channel);
channel.volumeRamp.active = false;
this.setLoopElementBaseVolume(channel, element, targetVolume);
return;
}
this.cancelLoopVolumeRampTimer(channel);
channel.volumeRamp.revision += 1;
channel.volumeRamp.active = true;
channel.volumeRamp.last = {
fromVolume,
toVolume: targetVolume,
durationMs: loopTargetVolumeRampMs
};
const revision = channel.volumeRamp.revision;
const startedAt = performance.now();
const update = () => {
if (
revision !== channel.volumeRamp.revision ||
channel.current !== element
) {
return;
}
const progress = Math.min(
1,
Math.max(
0,
(performance.now() - startedAt) / loopTargetVolumeRampMs
)
);
this.setLoopElementBaseVolume(
channel,
element,
fromVolume + (targetVolume - fromVolume) * progress
);
if (progress >= 1) {
this.completeLoopVolumeRamp(
channel,
revision,
targetVolume
);
}
};
channel.volumeRampTimer = window.setInterval(update, 16);
}
private completeLoopVolumeRamp(
channel: LoopChannel,
revision: number,
targetVolume: number
) {
if (revision !== channel.volumeRamp.revision) {
return;
}
this.cancelLoopVolumeRampTimer(channel);
if (channel.current) {
this.setLoopElementBaseVolume(
channel,
channel.current,
targetVolume
);
}
channel.volumeRamp.active = false;
channel.volumeRamp.completedRevision = revision;
}
private cancelLoopVolumeRampTimer(channel: LoopChannel) {
if (channel.volumeRampTimer !== undefined) {
window.clearInterval(channel.volumeRampTimer);
channel.volumeRampTimer = undefined;
}
}
private retireOutgoingElements(channel: LoopChannel) {
channel.outgoing.forEach((element) => this.retireLoopElement(channel, element));
channel.outgoing.clear();
@@ -1049,6 +1205,12 @@ export class SoundDirector {
completedRevision: channel.transition.completedRevision,
active: channel.transition.active,
last: channel.transition.last ? { ...channel.transition.last } : null
},
volumeRamp: {
revision: channel.volumeRamp.revision,
completedRevision: channel.volumeRamp.completedRevision,
active: channel.volumeRamp.active,
last: channel.volumeRamp.last ? { ...channel.volumeRamp.last } : null
}
};
}
@@ -1076,6 +1238,18 @@ export class SoundDirector {
return src ? { trackKey: key, src, poolKey: null } : undefined;
}
private pooledCombatEffectRateMultiplier(poolKey: string | null) {
if (!poolKey || !pooledCombatEffectKeys.has(poolKey)) {
return 1;
}
const playCount = this.pooledCombatEffectPlayCounts.get(poolKey) ?? 0;
this.pooledCombatEffectPlayCounts.set(poolKey, playCount + 1);
return pooledCombatRatePattern[
playCount % pooledCombatRatePattern.length
];
}
private refreshActiveEffectVolumes() {
this.activeEffects.forEach((effect) => {
const baseVolume = this.effectBaseVolumes.get(effect);
@@ -1190,6 +1364,13 @@ export class SoundDirector {
private normalizeDuration(value: number, fallback: number) {
return Number.isFinite(value) ? Math.min(10_000, Math.max(0, value)) : fallback;
}
private positivePatternIndex(value: number, length: number) {
const normalizedValue = Number.isFinite(value)
? Math.trunc(value)
: 0;
return ((normalizedValue % length) + length) % length;
}
}
export function movementSurfaceForTerrain(terrain?: MovementTerrain | string): MovementSurface {

View File

@@ -1,4 +1,8 @@
import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets';
import {
musicTrackIntegratedLoudnessLufs,
type AmbienceTrackKey,
type MusicTrackKey
} from '../audio/audioAssets';
import type { CampSkinId } from './campSkins';
export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier';
@@ -117,3 +121,41 @@ export function getCampSoundscape(skinId: CampSkinId): CampSoundscape {
ambience: campAmbienceDefinitions[mapping.ambienceId]
};
}
export function campMusicVolumeForTrackKey(
trackKey: string,
fallbackVolume = 0.2
) {
return Object.values(campMusicGroups).find(
(definition) => definition.trackKey === trackKey
)?.volume ?? fallbackVolume;
}
export function musicVolumeMatchedToCampTrack(
trackKey: MusicTrackKey,
referenceTrackKey: CampMusicTrackKey = 'camp-rally',
fallbackVolume = campMusicVolumeForTrackKey(referenceTrackKey)
) {
const trackLoudness = musicTrackIntegratedLoudnessLufs[trackKey];
const referenceLoudness =
musicTrackIntegratedLoudnessLufs[referenceTrackKey];
const referenceVolume = campMusicVolumeForTrackKey(
referenceTrackKey,
fallbackVolume
);
if (
!Number.isFinite(trackLoudness) ||
!Number.isFinite(referenceLoudness) ||
!Number.isFinite(referenceVolume) ||
referenceVolume <= 0
) {
return fallbackVolume;
}
const matchedVolume =
referenceVolume *
10 ** ((referenceLoudness - trackLoudness) / 20);
return Math.round(
Math.min(1, Math.max(0.01, matchedVolume)) * 1000
) / 1000;
}

View File

@@ -200,6 +200,55 @@ export function explorationCharacterAnimationKey(
return `${textureKey}-${motion}-${direction}`;
}
export function explorationCharacterFrameFor(
motion: ExplorationCharacterMotion,
direction: ExplorationCharacterDirection,
frameIndex = 0
) {
const rowStart =
explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection;
const motionStart = motion === 'walk' ? explorationCharacterIdleFrameCount : 0;
const motionFrameCount = motion === 'walk'
? explorationCharacterWalkFrameCount
: explorationCharacterIdleFrameCount;
const normalizedFrameIndex = Math.max(
0,
Math.min(motionFrameCount - 1, Math.trunc(frameIndex))
);
return rowStart + motionStart + normalizedFrameIndex;
}
export function applyExplorationCharacterMotion(
sprite: Phaser.GameObjects.Sprite,
textureKey: ExplorationCharacterTextureKey,
motion: ExplorationCharacterMotion,
direction: ExplorationCharacterDirection,
reducedMotion = false
) {
if (!sprite.scene.textures.exists(textureKey)) {
return false;
}
if (reducedMotion && motion === 'idle') {
sprite.anims.stop();
sprite.setFrame(explorationCharacterFrameFor('idle', direction));
return true;
}
const animationKey = explorationCharacterAnimationKey(
textureKey,
motion,
direction
);
if (
sprite.anims.currentAnim?.key !== animationKey ||
!sprite.anims.isPlaying
) {
sprite.play(animationKey);
}
return true;
}
export function ensureExplorationCharacterAnimations(
scene: Phaser.Scene,
textureKeysOrUnitTextureKeys: Iterable<string>
@@ -210,20 +259,18 @@ export function ensureExplorationCharacterAnimations(
}
explorationCharacterDirections.forEach((direction) => {
const rowStart =
explorationCharacterSheetRows[direction] * explorationCharacterFramesPerDirection;
const idleFrames = Array.from(
{ length: explorationCharacterIdleFrameCount },
(_, frameIndex) => ({
key,
frame: rowStart + frameIndex
frame: explorationCharacterFrameFor('idle', direction, frameIndex)
})
);
const walkFrames = Array.from(
{ length: explorationCharacterWalkFrameCount },
(_, frameIndex) => ({
key,
frame: rowStart + explorationCharacterIdleFrameCount + frameIndex
frame: explorationCharacterFrameFor('walk', direction, frameIndex)
})
);
const idleAnimationKey = explorationCharacterAnimationKey(key, 'idle', direction);

View File

@@ -4,14 +4,15 @@ import secondReliefBackgroundUrl from '../../assets/images/exploration/second-pu
import thirdCampBackgroundUrl from '../../assets/images/exploration/third-guangzong-sortie-camp.webp';
import { soundDirector } from '../audio/SoundDirector';
import {
applyExplorationCharacterMotion,
ensureExplorationCharacterAnimations,
explorationCharacterAnimationKey,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyByUnitTextureKey,
releaseExplorationCharacterTextureKeys,
type ExplorationCharacterDirection,
type ExplorationCharacterTextureKey
} from '../data/explorationCharacterAssets';
import { campMusicVolumeForTrackKey } from '../data/campSoundscapes';
import {
firstPursuitScoutSourceBattleId,
firstPursuitScoutVisitId
@@ -61,6 +62,7 @@ import {
recordThirdCampExplorationActivity,
thirdCampExplorationProgress
} from '../state/thirdCampExplorationActions';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { startGameScene } from './lazyScenes';
const sceneWidth = 1920;
@@ -337,9 +339,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
private lastNavigationTargetId?: InteractionTarget['id'];
private navigationReplanAttempts = 0;
private navigationDetourCount = 0;
private autoInteractionPending = false;
private autoInteractionTriggeredCount = 0;
private lastAutoInteractionTargetId?: ExplorationActorId;
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private autoDialogueInputReadyAt = 0;
private stepIndex = 0;
private lastStepAt = 0;
private lastNotice = '';
@@ -448,13 +454,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
? {
musicKey: 'camp-rally',
ambienceKey: 'riverside-village-day-ambience',
musicVolume: 0.2,
musicVolume: campMusicVolumeForTrackKey('camp-rally'),
ambienceVolume: 0.11
}
: {
musicKey: 'camp-rally',
ambienceKey: 'campfire-ambience',
musicVolume: 0.22,
musicVolume: campMusicVolumeForTrackKey('camp-rally'),
ambienceVolume: 0.12
}
);
@@ -494,7 +500,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
if (
this.consumeInteractionRequest() &&
time >= this.autoDialogueInputReadyAt
) {
this.advanceDialogue();
}
return;
@@ -575,6 +584,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
moving: this.playerMoving,
textureKey: this.player?.texture.key ?? null,
animationKey: this.player?.anims.currentAnim?.key ?? null,
animationPlaying: this.player?.anims.isPlaying ?? false,
bounds: this.player
? this.boundsSnapshot(this.player.getBounds())
: null
@@ -590,7 +600,16 @@ export class CampVisitExplorationScene extends Phaser.Scene {
waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })),
walkBounds: this.boundsSnapshot(activeMovementBounds),
collisionRadius: playerCollisionRadius,
detourCount: this.navigationDetourCount
detourCount: this.navigationDetourCount,
autoInteraction: {
pending: this.autoInteractionPending,
targetId: this.autoInteractionPending
? this.moveTargetId ?? null
: null,
triggeredCount: this.autoInteractionTriggeredCount,
lastTriggeredTargetId:
this.lastAutoInteractionTargetId ?? null
}
},
controls: {
movement: ['WASD', '방향키'],
@@ -628,6 +647,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
Math.abs(sprite.y - initialPosition.y) > 0.1,
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
animationPlaying: sprite.anims.isPlaying,
objectiveId: definition.objectiveId ?? null,
activityId: definition.activityId ?? null,
completed:
@@ -684,7 +704,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
portraitFrameVisible:
this.dialoguePortraitFrame?.visible ?? false,
bounds: this.dialoguePanel?.visible
? this.boundsSnapshot(this.dialoguePanel.getBounds())
? this.boundsSnapshot(
new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight)
)
: null
}
: {
@@ -1113,9 +1135,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.lastNavigationTargetId = undefined;
this.navigationReplanAttempts = 0;
this.navigationDetourCount = 0;
this.autoInteractionPending = false;
this.autoInteractionTriggeredCount = 0;
this.lastAutoInteractionTargetId = undefined;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.autoDialogueInputReadyAt = 0;
this.explorationInput = undefined;
this.stepIndex = 0;
this.lastStepAt = 0;
@@ -2182,11 +2208,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
const sprite = this.add
.sprite(x, y, resolvedTextureKey, 0)
.setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(
explorationCharacterAnimationKey(textureKey, 'idle', direction)
);
}
applyExplorationCharacterMotion(
sprite,
textureKey,
'idle',
direction,
this.visualMotionReduced
);
return sprite;
}
@@ -2279,6 +2307,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
return;
}
if (this.dialogueState) {
if (this.time.now < this.autoDialogueInputReadyAt) {
return;
}
this.advanceDialogue();
return;
}
@@ -2326,6 +2357,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
}
const keyboardDirection = this.keyboardMovementDirection();
let movement = keyboardDirection;
let pointerDistanceRemaining = Number.POSITIVE_INFINITY;
if (movement.lengthSq() > 0) {
this.clearNavigationTarget(true);
} else if (this.moveTarget) {
@@ -2333,12 +2365,19 @@ export class CampVisitExplorationScene extends Phaser.Scene {
const targetDelta = activeTarget
.clone()
.subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
if (targetDelta.length() <= navigationArrivalRadius) {
pointerDistanceRemaining = targetDelta.length();
if (pointerDistanceRemaining <= navigationArrivalRadius) {
if (this.moveWaypoints.length > 0) {
this.moveWaypoints.shift();
} else {
this.lastNavigationTargetId = this.moveTargetId;
const arrivedTargetId = this.moveTargetId;
const shouldAutoInteract = this.autoInteractionPending;
this.lastNavigationTargetId = arrivedTargetId;
this.clearNavigationTarget(false);
this.setPlayerMoving(false);
if (arrivedTargetId && shouldAutoInteract) {
this.triggerAutoInteraction(arrivedTargetId);
}
}
movement = new Phaser.Math.Vector2();
} else {
@@ -2352,7 +2391,10 @@ export class CampVisitExplorationScene extends Phaser.Scene {
}
movement.normalize();
this.playerDirection = this.directionForVector(movement);
const distance = (playerSpeed * Math.min(delta, 50)) / 1000;
const distance = Math.min(
(playerSpeed * Math.min(delta, 50)) / 1000,
pointerDistanceRemaining
);
const startX = this.player.x;
const startY = this.player.y;
const nextX = startX + movement.x * distance;
@@ -2464,6 +2506,9 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.moveTargetId = targetId;
this.lastNavigationTargetId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = Boolean(
targetId && targetId !== 'camp-exit'
);
if (route.length > 1) {
this.navigationDetourCount += 1;
}
@@ -2698,11 +2743,50 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.moveWaypoints = [];
this.moveTargetId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = false;
if (clearLast) {
this.lastNavigationTargetId = undefined;
}
}
private triggerAutoInteraction(targetId: InteractionTarget['id']) {
if (
this.navigationPending ||
this.dialogueState ||
this.choicePanel ||
!this.ready
) {
return;
}
const target = this.interactionTargetById(targetId);
if (!target || target.kind !== 'actor') {
return;
}
if (this.distanceTo(target.x, target.y) > interactionRadius + 8) {
const recoveryRoute = this.approachPositions(target.x, target.y)
.map((destination) => this.planNavigationRoute(destination))
.filter(
(route): route is Phaser.Math.Vector2[] => Boolean(route)
)
.sort(
(left, right) =>
this.navigationRouteLength(left) -
this.navigationRouteLength(right)
)[0];
if (recoveryRoute) {
this.applyNavigationRoute(recoveryRoute, target.id);
}
return;
}
this.autoInteractionTriggeredCount += 1;
this.lastAutoInteractionTargetId = target.id;
this.interactWithTarget(target);
if (this.dialogueState) {
this.autoDialogueInputReadyAt =
this.time.now + inputCarryoverGuardMs;
}
}
private consumeInteractionRequest() {
return this.explorationInput?.consumeInteractionRequest() ?? false;
}
@@ -2755,17 +2839,13 @@ export class CampVisitExplorationScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = explorationCharacterAnimationKey(
applyExplorationCharacterMotion(
this.player,
playerTextureKey,
moving ? 'walk' : 'idle',
this.playerDirection
this.playerDirection,
this.visualMotionReduced
);
if (
this.textures.exists(playerTextureKey) &&
this.player.anims.currentAnim?.key !== animationKey
) {
this.player.play(animationKey);
}
this.playerMoving = moving;
}
@@ -3110,22 +3190,22 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
view.sprite.play(
explorationCharacterAnimationKey(
view.definition.textureKey,
'idle',
npcDirection
)
applyExplorationCharacterMotion(
view.sprite,
view.definition.textureKey,
'idle',
npcDirection,
this.visualMotionReduced
);
}
private restoreActorDirection(view: ExplorationActorView) {
view.sprite.play(
explorationCharacterAnimationKey(
view.definition.textureKey,
'idle',
view.definition.direction
)
applyExplorationCharacterMotion(
view.sprite,
view.definition.textureKey,
'idle',
view.definition.direction,
this.visualMotionReduced
);
}
@@ -3184,7 +3264,7 @@ export class CampVisitExplorationScene extends Phaser.Scene {
this.dialoguePortrait
.setTexture(portraitEntry.textureKey)
.setVisible(true);
this.fitDialogueFacePortrait(this.dialoguePortrait);
fitDialogueFacePortrait(this.dialoguePortrait, 220);
return;
}
this.dialoguePortraitFrame?.setVisible(false);
@@ -3201,24 +3281,6 @@ export class CampVisitExplorationScene extends Phaser.Scene {
: 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) {
@@ -4059,25 +4121,26 @@ export class CampVisitExplorationScene extends Phaser.Scene {
align: 'center'
})
.setOrigin(0.5);
this.completionToast = this.add
const toast = this.add
.container(0, 0, [background, text])
.setDepth(3300);
if (this.visualMotionReduced) {
this.time.delayedCall(duration, () => {
this.completionToast?.destroy();
this.completionToast = toast;
const clearToast = () => {
toast.destroy();
if (this.completionToast === toast) {
this.completionToast = undefined;
});
}
};
if (this.visualMotionReduced) {
this.time.delayedCall(duration + 380, clearToast);
} else {
this.tweens.add({
targets: this.completionToast,
targets: toast,
alpha: 0,
y: -18,
delay: duration,
duration: 380,
onComplete: () => {
this.completionToast?.destroy();
this.completionToast = undefined;
}
onComplete: clearToast
});
}
}

View File

@@ -19,14 +19,15 @@ import {
type CityStayId
} from '../data/cityStays';
import type { BattleScenarioId } from '../data/battles';
import { campMusicVolumeForTrackKey } from '../data/campSoundscapes';
import {
equipmentSlotLabels,
getItem,
itemInventoryLabel
} from '../data/battleItems';
import {
applyExplorationCharacterMotion,
ensureExplorationCharacterAnimations,
explorationCharacterAnimationKey,
explorationCharacterAssetInfo,
explorationCharacterAssetInfos,
releaseExplorationCharacterTextureKeys,
@@ -40,6 +41,7 @@ import {
} from '../data/portraitAssets';
import { ExplorationInputController } from '../input/ExplorationInputController';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import {
chooseCityStayResonance,
collectCityStayInformation,
@@ -51,6 +53,7 @@ import {
setActiveCityStayId,
type CampaignState
} from '../state/campaignState';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { startGameScene } from './lazyScenes';
const sceneWidth = 1920;
@@ -200,9 +203,11 @@ export class CityStayScene extends Phaser.Scene {
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private autoDialogueInputReadyAt = 0;
private stepIndex = 0;
private lastStepAt = 0;
private lastNotice = '';
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
constructor() {
@@ -249,6 +254,7 @@ export class CityStayScene extends Phaser.Scene {
create() {
this.resetRuntimeState();
this.visualMotionReduced = isVisualMotionReduced();
this.markCityStayActive();
this.drawCity();
this.drawHud();
@@ -268,7 +274,7 @@ export class CityStayScene extends Phaser.Scene {
soundDirector.playSoundscape({
musicKey: this.profile.musicKey,
ambienceKey: this.profile.ambienceKey,
musicVolume: 0.68,
musicVolume: campMusicVolumeForTrackKey(this.profile.musicKey),
ambienceVolume: 0.1
});
@@ -301,7 +307,10 @@ export class CityStayScene extends Phaser.Scene {
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
if (
this.consumeInteractionRequest() &&
time >= this.autoDialogueInputReadyAt
) {
this.advanceDialogue();
}
return;
@@ -330,6 +339,7 @@ export class CityStayScene extends Phaser.Scene {
ready: this.ready,
cityStayId: this.cityStay.id,
cityName: this.cityStay.city.name,
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
activeCityStayId: campaign.activeCityStayId ?? null,
background: {
@@ -354,6 +364,7 @@ export class CityStayScene extends Phaser.Scene {
textureKey: this.player?.texture.key ?? null,
animationKey:
this.player?.anims.currentAnim?.key ?? null,
animationPlaying: this.player?.anims.isPlaying ?? false,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
@@ -400,6 +411,9 @@ export class CityStayScene extends Phaser.Scene {
marketOptional: true,
exitUnlocked: true
},
objective: {
dialogueUnitNames: this.cityDialogueUnitNames()
},
actors: Array.from(this.actors.values()).map(({ definition, sprite }) => ({
id: definition.id,
kind: definition.kind,
@@ -407,6 +421,7 @@ export class CityStayScene extends Phaser.Scene {
role: definition.role,
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
animationPlaying: sprite.anims.isPlaying,
x: sprite.x,
y: sprite.y,
initialX: definition.x,
@@ -583,6 +598,7 @@ export class CityStayScene extends Phaser.Scene {
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.autoDialogueInputReadyAt = 0;
this.explorationInput = undefined;
this.stepIndex = 0;
this.lastStepAt = 0;
@@ -1152,14 +1168,16 @@ export class CityStayScene extends Phaser.Scene {
backgroundColor: '#e5bd68',
padding: { left: 10, right: 10, top: 3, bottom: 3 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: marker,
y: marker.y - 8,
duration: 720,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
if (!this.visualMotionReduced) {
this.tweens.add({
targets: marker,
y: marker.y - 8,
duration: 720,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
this.actors.set(definition.id, {
definition,
textureKey,
@@ -1180,13 +1198,15 @@ export class CityStayScene extends Phaser.Scene {
backgroundColor: '#3a3024dd',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: exitMarker,
alpha: { from: 0.68, to: 1 },
duration: 880,
yoyo: true,
repeat: -1
});
if (!this.visualMotionReduced) {
this.tweens.add({
targets: exitMarker,
alpha: { from: 0.68, to: 1 },
duration: 880,
yoyo: true,
repeat: -1
});
}
}
private createCharacterSprite(
@@ -1198,15 +1218,13 @@ export class CityStayScene extends Phaser.Scene {
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(
explorationCharacterAnimationKey(
textureKey,
'idle',
direction
)
);
}
applyExplorationCharacterMotion(
sprite,
textureKey,
'idle',
direction,
this.visualMotionReduced
);
return sprite;
}
@@ -1290,6 +1308,9 @@ export class CityStayScene extends Phaser.Scene {
return;
}
if (this.dialogueState) {
if (this.time.now < this.autoDialogueInputReadyAt) {
return;
}
this.advanceDialogue();
return;
}
@@ -1801,6 +1822,10 @@ export class CityStayScene extends Phaser.Scene {
this.autoInteractionTriggeredCount += 1;
this.lastAutoInteractionTargetId = target.id;
this.interactWithTarget(target);
if (this.dialogueState) {
this.autoDialogueInputReadyAt =
this.time.now + inputCarryoverGuardMs;
}
}
private consumeInteractionRequest() {
@@ -1850,17 +1875,13 @@ export class CityStayScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = explorationCharacterAnimationKey(
applyExplorationCharacterMotion(
this.player,
playerTextureKey,
moving ? 'walk' : 'idle',
this.playerDirection
this.playerDirection,
this.visualMotionReduced
);
if (
this.textures.exists(playerTextureKey) &&
this.player.anims.currentAnim?.key !== animationKey
) {
this.player.play(animationKey);
}
this.playerMoving = moving;
}
@@ -2142,11 +2163,16 @@ export class CityStayScene extends Phaser.Scene {
soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 });
this.refreshProgressHud();
this.refreshActorMarkers();
const companion = this.companionActor();
const companionView = this.actors.get(companion.id);
if (companionView) {
this.faceCharactersForConversation(companionView);
}
this.startDialogue([
{
speaker: this.companionActor().name,
speaker: companion.name,
portraitKey: this.actorPortraitKey(
this.companionActor()
companion
),
text: choice.response
},
@@ -2154,7 +2180,9 @@ export class CityStayScene extends Phaser.Scene {
speaker: '공명',
text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}`
}
], () => this.showCompletionToast('동료 공명 대화 완료'), this.companionActor().id);
], () => this.showCompletionToast('동료 공명 대화 완료'), companion.id);
this.autoDialogueInputReadyAt =
this.time.now + inputCarryoverGuardMs;
}
private closeChoicePanel() {
@@ -2558,12 +2586,16 @@ export class CityStayScene extends Phaser.Scene {
this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle])
.setDepth(5000)
.setAlpha(0);
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
if (this.visualMotionReduced) {
this.transitionOverlay.setAlpha(1);
} else {
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
}
}
private startDialogue(
@@ -2616,7 +2648,10 @@ export class CityStayScene extends Phaser.Scene {
this.dialoguePortrait
.setTexture(portraitEntry.textureKey)
.setVisible(true);
this.fitDialogueFacePortrait(this.dialoguePortrait);
fitDialogueFacePortrait(
this.dialoguePortrait,
dialoguePortraitDisplaySize
);
return;
}
this.dialoguePortraitFrame?.setVisible(false);
@@ -2638,30 +2673,6 @@ export class CityStayScene extends Phaser.Scene {
);
}
private fitDialogueFacePortrait(
portrait: Phaser.GameObjects.Image
) {
const sourceWidth = portrait.frame.realWidth;
const sourceHeight = portrait.frame.realHeight;
const cropSize = Math.round(
Math.min(sourceWidth, sourceHeight) * 0.6
);
const cropX = Math.round((sourceWidth - cropSize) / 2);
const cropY = Math.round(
Math.min(
Math.max(0, sourceHeight * 0.035),
sourceHeight - cropSize
)
);
portrait
.setCrop(cropX, cropY, cropSize, cropSize)
.setOrigin(
(cropX + cropSize / 2) / sourceWidth,
(cropY + cropSize / 2) / sourceHeight
)
.setScale(dialoguePortraitDisplaySize / cropSize);
}
private advanceDialogue() {
const dialogue = this.dialogueState;
if (!dialogue || this.navigationPending) {
@@ -2675,17 +2686,21 @@ export class CityStayScene extends Phaser.Scene {
}
const onComplete = dialogue.onComplete;
const sourceTargetId = dialogue.sourceTargetId;
this.dialogueState = undefined;
this.dialoguePanel?.setVisible(false);
this.stopPlayerMovement();
this.restoreActorDirection(sourceTargetId);
onComplete?.();
this.refreshInteractionPrompt();
}
private cancelDialogue() {
const sourceTargetId = this.dialogueState?.sourceTargetId;
this.dialogueState = undefined;
this.dialoguePanel?.setVisible(false);
this.stopPlayerMovement();
this.restoreActorDirection(sourceTargetId);
this.refreshInteractionPrompt();
}
@@ -2775,18 +2790,26 @@ export class CityStayScene extends Phaser.Scene {
wordWrap: { width: 470, useAdvancedWrap: true },
align: 'center'
}).setOrigin(0.5);
this.completionToast = this.add.container(0, 0, [background, text]).setDepth(3300);
this.tweens.add({
targets: this.completionToast,
alpha: 0,
y: -18,
delay: 1500,
duration: 380,
onComplete: () => {
this.completionToast?.destroy();
const toast = this.add.container(0, 0, [background, text]).setDepth(3300);
this.completionToast = toast;
const clearToast = () => {
toast.destroy();
if (this.completionToast === toast) {
this.completionToast = undefined;
}
});
};
if (this.visualMotionReduced) {
this.time.delayedCall(1880, clearToast);
} else {
this.tweens.add({
targets: toast,
alpha: 0,
y: -18,
delay: 1500,
duration: 380,
onComplete: clearToast
});
}
}
private nearestInteractionTarget(radius: number) {
@@ -2834,15 +2857,27 @@ export class CityStayScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.textureKey)) {
view.sprite.play(
explorationCharacterAnimationKey(
view.textureKey,
'idle',
npcDirection
)
);
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'idle',
npcDirection,
this.visualMotionReduced
);
}
private restoreActorDirection(targetId?: string) {
const view = targetId ? this.actors.get(targetId) : undefined;
if (!view) {
return;
}
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'idle',
view.definition.direction,
this.visualMotionReduced
);
}
private informationComplete(campaign = getCampaignState()) {
@@ -2854,11 +2889,8 @@ export class CityStayScene extends Phaser.Scene {
}
private cityDialogueUnitNames() {
const campaign = this.campaign ?? getCampaignState();
return this.cityStay.dialogue.unitIds
.map((unitId) => campaign.roster.find((unit) => unit.id === unitId)?.name ?? (
unitId === 'liu-bei' ? '유비' : this.companionActor().name
))
.map((unitId) => unitId === 'liu-bei' ? '유비' : this.companionActor().name)
.join(' · ');
}

View File

@@ -22,6 +22,7 @@ import {
type SortieOrderId
} from '../data/sortieOrders';
import {
applyExplorationCharacterMotion,
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyForUnitTextureKey,
@@ -41,6 +42,8 @@ import {
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -54,6 +57,10 @@ const interactionRadius = 122;
const promptRadius = 164;
const characterDisplaySize = 104;
const inputCarryoverGuardMs = 320;
const navigationArrivalRadius = 7;
const navigationDetourClearance = 58;
const navigationProbeStep = 18;
const directPointerSnapRadius = 96;
const campBackgroundKey = 'prologue-militia-camp-background';
const campDialoguePortraitContext = {
id: 'prologue-militia-camp-dialogue',
@@ -203,13 +210,23 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
};
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
private moveTarget?: Phaser.Math.Vector2;
private moveWaypoints: Phaser.Math.Vector2[] = [];
private moveTargetNpcId?: string;
private lastNavigationTargetId?: string;
private navigationReplanAttempts = 0;
private navigationDetourCount = 0;
private autoInteractionPending = false;
private autoInteractionTriggeredCount = 0;
private lastAutoInteractionTargetId?: string;
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private autoDialogueInputReadyAt = 0;
private interactionQueued = false;
private stepIndex = 0;
private lastStepAt = 0;
private firstVisit = false;
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
constructor() {
@@ -240,6 +257,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
create() {
this.resetRuntimeState();
this.visualMotionReduced = isVisualMotionReduced();
this.prepareCampaignProgress();
this.drawCamp();
this.drawHud();
@@ -256,7 +274,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.ready = false;
this.navigationPending = false;
this.moveTarget = undefined;
this.clearNavigationTarget(true);
this.dialogueState = undefined;
this.closeCommandChoice();
this.stopNpcMovement();
@@ -302,7 +320,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
if (
this.consumeInteractionRequest() &&
time >= this.autoDialogueInputReadyAt
) {
this.advanceDialogue();
}
return;
@@ -320,12 +341,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
const playerPosition = this.player ? { x: this.player.x, y: this.player.y } : null;
const target = this.nearestNpc(promptRadius);
const dialogueBounds = this.dialoguePanel?.visible
? this.boundsSnapshot(this.dialoguePanel.getBounds())
? this.boundsSnapshot(
new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight)
)
: null;
return {
scene: this.scene.key,
locationId: 'zhuo-volunteer-camp',
ready: this.ready,
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
requiredTextures: characterTextureKeys.map((key) => ({
@@ -351,14 +375,31 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
moving: this.playerMoving,
textureKey: this.player?.texture.key ?? null,
animationKey: this.player?.anims.currentAnim?.key ?? null,
animationPlaying: this.player?.anims.isPlaying ?? false,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
movement: {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
targetNpcId: this.moveTargetNpcId ?? null,
lastTargetNpcId: this.lastNavigationTargetId ?? null,
activeWaypoint: this.moveWaypoints[0]
? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y }
: null,
waypoints: this.moveWaypoints.map(({ x, y }) => ({ x, y })),
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius,
detourCount: this.navigationDetourCount,
replanAttempts: this.navigationReplanAttempts,
autoInteraction: {
pending: this.autoInteractionPending,
targetId: this.autoInteractionPending
? this.moveTargetNpcId ?? null
: null,
triggeredCount: this.autoInteractionTriggeredCount,
lastTriggeredTargetId: this.lastAutoInteractionTargetId ?? null
},
npcMovementPending: this.npcMovementPending,
movingNpcIds: [...this.npcMoveTweens.keys()]
},
@@ -367,7 +408,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
interact: ['E', 'Space', 'Enter'],
commandChoice: ['1', '2', '3', 'pointer'],
pointerMove: true,
carryoverGuardMs: inputCarryoverGuardMs
carryoverGuardMs: inputCarryoverGuardMs,
reducedMotion: this.visualMotionReduced
},
interaction: {
radius: interactionRadius,
@@ -408,6 +450,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
y: sprite.y,
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
animationPlaying: sprite.anims.isPlaying,
bounds: this.boundsSnapshot(sprite.getBounds())
})) ?? []
};
@@ -429,6 +472,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
moving: this.npcMoveTweens.has(definition.id),
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
animationPlaying: sprite.anims.isPlaying,
distance: playerPosition
? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y)
: null,
@@ -586,9 +630,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.pendingCommandOrderId = undefined;
this.commandChoiceReadyAt = Number.POSITIVE_INFINITY;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.lastNavigationTargetId = undefined;
this.navigationReplanAttempts = 0;
this.navigationDetourCount = 0;
this.autoInteractionPending = false;
this.autoInteractionTriggeredCount = 0;
this.lastAutoInteractionTargetId = undefined;
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.autoDialogueInputReadyAt = 0;
this.interactionQueued = false;
this.stepIndex = 0;
this.lastStepAt = 0;
@@ -689,16 +742,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private drawCampFireGlow() {
const glow = this.add.ellipse(780, 540, 230, 150, 0xf2a241, 0.08).setDepth(35);
this.tweens.add({
targets: glow,
alpha: { from: 0.05, to: 0.14 },
scaleX: { from: 0.95, to: 1.05 },
scaleY: { from: 0.95, to: 1.05 },
duration: 880,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
if (!this.visualMotionReduced) {
this.tweens.add({
targets: glow,
alpha: { from: 0.05, to: 0.14 },
scaleX: { from: 0.95, to: 1.05 },
scaleY: { from: 0.95, to: 1.05 },
duration: 880,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
}
private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) {
@@ -835,16 +890,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.addBlocker(728, 520, 104, 64);
const glow = this.add.ellipse(780, 540, 250, 170, 0xf2a241, 0.11).setDepth(35);
this.tweens.add({
targets: glow,
alpha: { from: 0.07, to: 0.16 },
scaleX: { from: 0.94, to: 1.05 },
scaleY: { from: 0.94, to: 1.05 },
duration: 820,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
if (!this.visualMotionReduced) {
this.tweens.add({
targets: glow,
alpha: { from: 0.07, to: 0.16 },
scaleX: { from: 0.94, to: 1.05 },
scaleY: { from: 0.94, to: 1.05 },
duration: 820,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
[975, 1100, 1225].forEach((x, index) => {
const training = this.add.graphics().setDepth(39);
@@ -1042,7 +1099,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
view.container.setVisible(false).setAlpha(0).setY(0);
return;
}
if (animatedObjectiveId !== objectiveId || view.container.visible) {
if (
this.visualMotionReduced ||
animatedObjectiveId !== objectiveId ||
view.container.visible
) {
view.container.setVisible(true).setAlpha(1).setY(0);
return;
}
@@ -1213,7 +1274,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
padding: { left: 11, right: 11, top: 2, bottom: 2 }
}).setOrigin(0.5).setDepth(1201)
: undefined;
if (marker) {
if (marker && !this.visualMotionReduced) {
this.tweens.add({
targets: marker,
y: marker.y - 8,
@@ -1244,9 +1305,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(`${textureKey}-idle-${direction}`);
}
applyExplorationCharacterMotion(
sprite,
textureKey,
'idle',
direction,
this.visualMotionReduced
);
return sprite;
}
@@ -1338,13 +1403,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
if (
!this.ready ||
this.navigationPending ||
this.npcMovementPending ||
this.time.now < this.inputReadyAt
) {
return;
}
if (this.commandChoicePanel) {
return;
}
if (this.dialogueState) {
if (this.time.now < this.autoDialogueInputReadyAt) {
return;
}
this.advanceDialogue();
return;
}
@@ -1354,7 +1427,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
pointer.y >= movementBounds.top &&
pointer.y <= movementBounds.bottom
) {
this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y);
this.beginPointerMovement(pointer.x, pointer.y);
}
});
}
@@ -1365,14 +1438,28 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
const keyboardDirection = this.keyboardMovementDirection();
let movement = keyboardDirection;
let pointerDistanceRemaining = Number.POSITIVE_INFINITY;
if (movement.lengthSq() > 0) {
this.moveTarget = undefined;
this.clearNavigationTarget(true);
} else if (this.moveTarget) {
const targetDelta = this.moveTarget.clone().subtract(
const activeTarget = this.moveWaypoints[0] ?? this.moveTarget;
const targetDelta = activeTarget.clone().subtract(
new Phaser.Math.Vector2(this.player.x, this.player.y)
);
if (targetDelta.length() <= 7) {
this.moveTarget = undefined;
pointerDistanceRemaining = targetDelta.length();
if (pointerDistanceRemaining <= navigationArrivalRadius) {
if (this.moveWaypoints.length > 0) {
this.moveWaypoints.shift();
} else {
const arrivedTargetId = this.moveTargetNpcId;
const shouldAutoInteract = this.autoInteractionPending;
this.lastNavigationTargetId = arrivedTargetId;
this.clearNavigationTarget(false);
this.setPlayerMoving(false);
if (arrivedTargetId && shouldAutoInteract) {
this.triggerAutoInteraction(arrivedTargetId);
}
}
movement = new Phaser.Math.Vector2();
} else {
movement = targetDelta.normalize();
@@ -1386,7 +1473,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
movement.normalize();
this.playerDirection = this.directionForVector(movement);
const distance = playerSpeed * Math.min(delta, 50) / 1000;
const distance = Math.min(
playerSpeed * Math.min(delta, 50) / 1000,
pointerDistanceRemaining
);
const startX = this.player.x;
const startY = this.player.y;
const nextX = startX + movement.x * distance;
@@ -1402,7 +1492,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
moved = true;
}
if (!moved && this.moveTarget) {
this.moveTarget = undefined;
if (!this.replanBlockedPointerMovement()) {
this.clearNavigationTarget(true);
}
}
this.syncPlayerView();
@@ -1419,6 +1511,385 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
}
private beginPointerMovement(x: number, y: number) {
const snappedTarget = this.pointerMovementTargetAt(x, y);
if (!snappedTarget) {
this.beginNavigationTo(x, y);
return;
}
const plannedApproaches = this.approachPositions(
snappedTarget.x,
snappedTarget.y
)
.map((destination) => ({
destination,
route: this.planNavigationRoute(destination)
}))
.filter(
(
entry
): entry is {
destination: Phaser.Math.Vector2;
route: Phaser.Math.Vector2[];
} => Boolean(entry.route)
)
.sort(
(left, right) =>
this.navigationRouteLength(left.route) -
this.navigationRouteLength(right.route)
);
const best = plannedApproaches[0];
if (best) {
this.applyNavigationRoute(best.route, snappedTarget.id);
return;
}
const fallback = this.safeApproachPosition(
snappedTarget.x,
snappedTarget.y
);
this.beginNavigationTo(
fallback.x,
fallback.y,
snappedTarget.id
);
}
private beginNavigationTo(
x: number,
y: number,
targetNpcId?: string
) {
const destination = this.nearestWalkablePoint(x, y);
if (!destination) {
this.clearNavigationTarget(true);
return;
}
const route = this.planNavigationRoute(destination);
if (!route) {
this.clearNavigationTarget(true);
return;
}
this.applyNavigationRoute(route, targetNpcId);
}
private applyNavigationRoute(
route: Phaser.Math.Vector2[],
targetNpcId?: string
) {
const destination = route[route.length - 1];
if (!destination) {
this.clearNavigationTarget(true);
return;
}
this.moveTarget = destination.clone();
this.moveWaypoints = route
.slice(0, -1)
.map((waypoint) => waypoint.clone());
this.moveTargetNpcId = targetNpcId;
this.lastNavigationTargetId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = Boolean(targetNpcId);
if (route.length > 1) {
this.navigationDetourCount += 1;
}
}
private pointerMovementTargetAt(x: number, y: number) {
return Array.from(this.npcViews.values())
.map((view) => ({
id: view.definition.id,
x: view.sprite.x,
y: view.sprite.y,
distance: Phaser.Math.Distance.Between(
x,
y,
view.sprite.x,
view.sprite.y
)
}))
.filter(({ distance }) => distance <= directPointerSnapRadius)
.sort((left, right) => left.distance - right.distance)[0];
}
private approachPositions(targetX: number, targetY: number) {
return [
new Phaser.Math.Vector2(targetX, targetY + 88),
new Phaser.Math.Vector2(targetX - 88, targetY),
new Phaser.Math.Vector2(targetX + 88, targetY),
new Phaser.Math.Vector2(targetX, targetY - 88)
].filter(({ x, y }) => this.canPlayerOccupy(x, y));
}
private nearestWalkablePoint(x: number, y: number) {
const margin = playerCollisionRadius + 4;
const clamped = new Phaser.Math.Vector2(
Phaser.Math.Clamp(
x,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
y,
movementBounds.top + margin,
movementBounds.bottom - margin
)
);
if (this.canPlayerOccupy(clamped.x, clamped.y)) {
return clamped;
}
for (const radius of [48, 80, 112, 144]) {
for (let index = 0; index < 8; index += 1) {
const angle = (index * Math.PI) / 4;
const candidate = new Phaser.Math.Vector2(
Phaser.Math.Clamp(
clamped.x + Math.cos(angle) * radius,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
clamped.y + Math.sin(angle) * radius,
movementBounds.top + margin,
movementBounds.bottom - margin
)
);
if (this.canPlayerOccupy(candidate.x, candidate.y)) {
return candidate;
}
}
}
return undefined;
}
private planNavigationRoute(destination: Phaser.Math.Vector2) {
if (
!this.player ||
!this.canPlayerOccupy(destination.x, destination.y)
) {
return undefined;
}
const start = new Phaser.Math.Vector2(this.player.x, this.player.y);
if (this.segmentIsNavigable(start, destination)) {
return [destination.clone()];
}
const candidates = this.navigationDetourCandidates(start, destination);
let bestRoute: Phaser.Math.Vector2[] | undefined;
let bestLength = Number.POSITIVE_INFINITY;
const startVisible = candidates.filter((candidate) =>
this.segmentIsNavigable(start, candidate)
);
const destinationVisible = candidates.filter((candidate) =>
this.segmentIsNavigable(candidate, destination)
);
startVisible.forEach((waypoint) => {
if (!this.segmentIsNavigable(waypoint, destination)) {
return;
}
const route = [waypoint, destination];
const length = this.navigationRouteLength(route);
if (length < bestLength) {
bestLength = length;
bestRoute = route;
}
});
startVisible.forEach((first) => {
destinationVisible.forEach((second) => {
if (
first.equals(second) ||
!this.segmentIsNavigable(first, second)
) {
return;
}
const route = [first, second, destination];
const length = this.navigationRouteLength(route);
if (length < bestLength) {
bestLength = length;
bestRoute = route;
}
});
});
return bestRoute?.map((waypoint) => waypoint.clone());
}
private navigationDetourCandidates(
start: Phaser.Math.Vector2,
destination: Phaser.Math.Vector2
) {
const margin = playerCollisionRadius + 4;
const candidates = [
new Phaser.Math.Vector2(start.x, destination.y),
new Phaser.Math.Vector2(destination.x, start.y)
];
this.blockers.forEach((blocker) => {
const left = blocker.left - navigationDetourClearance;
const right = blocker.right + navigationDetourClearance;
const top = blocker.top - navigationDetourClearance;
const bottom = blocker.bottom + navigationDetourClearance;
candidates.push(
new Phaser.Math.Vector2(left, top),
new Phaser.Math.Vector2(right, top),
new Phaser.Math.Vector2(left, bottom),
new Phaser.Math.Vector2(right, bottom)
);
});
this.npcViews.forEach(({ sprite }) => {
candidates.push(
new Phaser.Math.Vector2(sprite.x - 72, sprite.y),
new Phaser.Math.Vector2(sprite.x + 72, sprite.y),
new Phaser.Math.Vector2(sprite.x, sprite.y - 72),
new Phaser.Math.Vector2(sprite.x, sprite.y + 72)
);
});
const unique = new Map<string, Phaser.Math.Vector2>();
candidates.forEach((candidate) => {
candidate.set(
Phaser.Math.Clamp(
candidate.x,
movementBounds.left + margin,
movementBounds.right - margin
),
Phaser.Math.Clamp(
candidate.y,
movementBounds.top + margin,
movementBounds.bottom - margin
)
);
if (!this.canPlayerOccupy(candidate.x, candidate.y)) {
return;
}
unique.set(
`${Math.round(candidate.x)}:${Math.round(candidate.y)}`,
candidate
);
});
return Array.from(unique.values());
}
private segmentIsNavigable(
from: Phaser.Math.Vector2,
to: Phaser.Math.Vector2
) {
const distance = Phaser.Math.Distance.Between(
from.x,
from.y,
to.x,
to.y
);
const steps = Math.max(
1,
Math.ceil(distance / navigationProbeStep)
);
for (let index = 1; index <= steps; index += 1) {
const progress = index / steps;
const x = Phaser.Math.Linear(from.x, to.x, progress);
const y = Phaser.Math.Linear(from.y, to.y, progress);
if (!this.canPlayerOccupy(x, y)) {
return false;
}
}
return true;
}
private navigationRouteLength(
route: readonly Phaser.Math.Vector2[]
) {
if (!this.player) {
return Number.POSITIVE_INFINITY;
}
let previous = new Phaser.Math.Vector2(
this.player.x,
this.player.y
);
return route.reduce((total, waypoint) => {
const length = Phaser.Math.Distance.Between(
previous.x,
previous.y,
waypoint.x,
waypoint.y
);
previous = waypoint;
return total + length;
}, 0);
}
private replanBlockedPointerMovement() {
if (
!this.moveTarget ||
this.navigationReplanAttempts >= 2
) {
return false;
}
this.navigationReplanAttempts += 1;
const route = this.planNavigationRoute(this.moveTarget);
if (!route) {
return false;
}
this.moveWaypoints = route
.slice(0, -1)
.map((waypoint) => waypoint.clone());
return true;
}
private clearNavigationTarget(clearLast = false) {
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = false;
if (clearLast) {
this.lastNavigationTargetId = undefined;
}
}
private triggerAutoInteraction(npcId: string) {
if (
this.navigationPending ||
this.npcMovementPending ||
this.dialogueState ||
this.commandChoicePanel ||
!this.ready
) {
return;
}
const view = this.npcViews.get(npcId);
if (!view) {
return;
}
if (
this.distanceTo(view.sprite.x, view.sprite.y) >
interactionRadius + 8
) {
const recoveryRoute = this.approachPositions(
view.sprite.x,
view.sprite.y
)
.map((destination) => this.planNavigationRoute(destination))
.filter(
(route): route is Phaser.Math.Vector2[] => Boolean(route)
)
.sort(
(left, right) =>
this.navigationRouteLength(left) -
this.navigationRouteLength(right)
)[0];
if (recoveryRoute) {
this.applyNavigationRoute(recoveryRoute, npcId);
return;
}
this.showPromptMessage(`${view.definition.name}에게 다가갈 길을 찾지 못했습니다.`);
return;
}
this.autoInteractionTriggeredCount += 1;
this.lastAutoInteractionTargetId = npcId;
this.interactWithNpc(view);
if (this.dialogueState) {
this.autoDialogueInputReadyAt =
this.time.now + inputCarryoverGuardMs;
}
}
private consumeInteractionRequest() {
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
const requested = this.interactionQueued || keyPressedThisFrame;
@@ -1477,15 +1948,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) {
this.player.play(animationKey);
}
applyExplorationCharacterMotion(
this.player,
playerTextureKey,
moving ? 'walk' : 'idle',
this.playerDirection,
this.visualMotionReduced
);
this.playerMoving = moving;
}
private stopPlayerMovement() {
this.moveTarget = undefined;
this.clearNavigationTarget();
this.setPlayerMoving(false);
}
@@ -1601,8 +2075,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
fitDialogueFacePortrait(this.dialoguePortrait, 218);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
@@ -2062,12 +2536,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle])
.setDepth(5000)
.setAlpha(0);
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
if (this.visualMotionReduced) {
this.transitionOverlay.setAlpha(1);
} else {
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
}
}
private showCompletionToast(label: string) {
@@ -2080,18 +2558,26 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
color: '#dff0c9',
fontStyle: 'bold'
}).setOrigin(0.5);
this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200);
this.tweens.add({
targets: this.completionToast,
alpha: 0,
y: -18,
delay: 1150,
duration: 360,
onComplete: () => {
this.completionToast?.destroy();
const toast = this.add.container(0, 0, [background, text]).setDepth(2200);
this.completionToast = toast;
const clearToast = () => {
toast.destroy();
if (this.completionToast === toast) {
this.completionToast = undefined;
}
});
};
if (this.visualMotionReduced) {
this.time.delayedCall(1510, clearToast);
} else {
this.tweens.add({
targets: toast,
alpha: 0,
y: -18,
delay: 1150,
duration: 360,
onComplete: clearToast
});
}
}
private showPromptMessage(message: string) {
@@ -2204,9 +2690,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${npcDirection}`);
}
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'idle',
npcDirection,
this.visualMotionReduced
);
}
private gatherCommandParty(onComplete: () => void) {
@@ -2219,8 +2709,19 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
this.promptText?.setVisible(false);
const movements = [
{ npcId: 'guan-yu', x: 650, y: 420, direction: 'north' as const },
{ npcId: 'zhang-fei', x: 890, y: 420, direction: 'north' as const }
{
npcId: 'guan-yu',
waypoints: [{ x: 650, y: 420 }],
direction: 'north' as const
},
{
npcId: 'zhang-fei',
waypoints: [
{ x: 890, y: 560 },
{ x: 890, y: 420 }
],
direction: 'north' as const
}
].filter(({ npcId }) => this.npcViews.has(npcId));
if (movements.length === 0) {
this.npcMovementPending = false;
@@ -2229,8 +2730,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
}
let remaining = movements.length;
movements.forEach(({ npcId, x, y, direction }) => {
this.walkNpcTo(npcId, x, y, direction, () => {
movements.forEach(({ npcId, waypoints, direction }) => {
this.walkNpcTo(npcId, waypoints, direction, () => {
remaining -= 1;
if (remaining > 0) {
return;
@@ -2243,8 +2744,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
private walkNpcTo(
npcId: string,
x: number,
y: number,
waypoints: readonly { x: number; y: number }[],
finalDirection: ExplorationCharacterDirection,
onComplete: () => void
) {
@@ -2254,31 +2754,63 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
return;
}
this.npcMoveTweens.get(npcId)?.remove();
const delta = new Phaser.Math.Vector2(x - view.sprite.x, y - view.sprite.y);
const walkDirection = delta.lengthSq() > 0
? this.directionForVector(delta)
: finalDirection;
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-walk-${walkDirection}`);
}
const duration = Math.max(220, Math.round(delta.length() / playerSpeed * 1000));
const tween = this.tweens.add({
targets: view.sprite,
x,
y,
duration,
ease: 'Sine.easeInOut',
onUpdate: () => this.syncNpcView(view),
onComplete: () => {
const remainingWaypoints = waypoints.map(({ x, y }) => ({ x, y }));
const walkNext = () => {
const target = remainingWaypoints.shift();
if (!target) {
this.npcMoveTweens.delete(npcId);
this.syncNpcView(view);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${finalDirection}`);
}
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'idle',
finalDirection,
this.visualMotionReduced
);
onComplete();
return;
}
});
this.npcMoveTweens.set(npcId, tween);
const delta = new Phaser.Math.Vector2(
target.x - view.sprite.x,
target.y - view.sprite.y
);
if (delta.lengthSq() <= 1) {
view.sprite.setPosition(target.x, target.y);
this.syncNpcView(view);
walkNext();
return;
}
const walkDirection = this.directionForVector(delta);
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'walk',
walkDirection,
this.visualMotionReduced
);
let tween: Phaser.Tweens.Tween | undefined;
tween = this.tweens.add({
targets: view.sprite,
x: target.x,
y: target.y,
duration: Math.max(
180,
Math.round(delta.length() / playerSpeed * 1000)
),
ease: 'Linear',
onUpdate: () => this.syncNpcView(view),
onComplete: () => {
if (!tween || this.npcMoveTweens.get(npcId) !== tween) {
return;
}
view.sprite.setPosition(target.x, target.y);
this.syncNpcView(view);
walkNext();
}
});
this.npcMoveTweens.set(npcId, tween);
};
walkNext();
}
private syncNpcView(view: CampNpcView) {
@@ -2309,9 +2841,13 @@ export class PrologueMilitiaCampScene extends Phaser.Scene {
view.sprite.setPosition(x, y);
this.syncNpcView(view);
view.marker?.setPosition(x, y - 72).setVisible(false);
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${direction}`);
}
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
'idle',
direction,
this.visualMotionReduced
);
}
private isObjectiveComplete(objectiveId: PrologueMilitiaCampRequiredObjectiveId) {

View File

@@ -16,6 +16,7 @@ import {
type PrologueVillageRequiredObjectiveId
} from '../data/prologueVillage';
import {
applyExplorationCharacterMotion,
ensureExplorationCharacterAnimations,
explorationCharacterAssetInfos,
explorationCharacterTextureKeyForUnitTextureKey,
@@ -25,6 +26,7 @@ import {
type ExplorationCharacterDirection,
type ExplorationCharacterTextureKey
} from '../data/explorationCharacterAssets';
import { musicVolumeMatchedToCampTrack } from '../data/campSoundscapes';
import {
completeCampaignTutorial,
getCampaignState,
@@ -34,6 +36,8 @@ import {
type CampaignTutorialId
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { startGameScene } from './lazyScenes';
@@ -218,6 +222,10 @@ export class PrologueVillageScene extends Phaser.Scene {
private moveTarget?: Phaser.Math.Vector2;
private moveWaypoints: Phaser.Math.Vector2[] = [];
private moveTargetNpcId?: string;
private lastNavigationTargetId?: string;
private autoInteractionPending = false;
private autoInteractionTriggeredCount = 0;
private lastAutoInteractionTargetId?: string;
private queuedMovementIntent?: QueuedMovementIntent;
private queuedMovementIntentCount = 0;
private appliedQueuedMovementIntentCount = 0;
@@ -232,10 +240,12 @@ export class PrologueVillageScene extends Phaser.Scene {
private ready = false;
private navigationPending = false;
private inputReadyAt = Number.POSITIVE_INFINITY;
private autoDialogueInputReadyAt = 0;
private interactionQueued = false;
private stepIndex = 0;
private lastStepAt = 0;
private firstVisit = false;
private visualMotionReduced = false;
private ownedPresentationTextureKeys = new Set<string>();
constructor() {
@@ -266,6 +276,7 @@ export class PrologueVillageScene extends Phaser.Scene {
create() {
this.resetRuntimeState();
this.visualMotionReduced = isVisualMotionReduced();
this.prepareCampaignProgress();
this.drawVillage();
this.drawHud();
@@ -275,7 +286,7 @@ export class PrologueVillageScene extends Phaser.Scene {
soundDirector.playSoundscape({
musicKey: 'militia-theme',
ambienceKey: 'mountain-wind-ambience',
musicVolume: 0.72,
musicVolume: musicVolumeMatchedToCampTrack('militia-theme'),
ambienceVolume: 0.11
});
@@ -283,9 +294,7 @@ export class PrologueVillageScene extends Phaser.Scene {
this.ready = false;
this.navigationPending = false;
this.oathGatherPending = false;
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.clearNavigationTarget(true);
this.queuedMovementIntent = undefined;
this.dialogueState = undefined;
this.stopAllNpcMovement();
@@ -330,7 +339,10 @@ export class PrologueVillageScene extends Phaser.Scene {
if (this.dialogueState) {
this.stopPlayerMovement();
if (this.consumeInteractionRequest()) {
if (
this.consumeInteractionRequest() &&
time >= this.autoDialogueInputReadyAt
) {
this.advanceDialogue();
}
return;
@@ -354,12 +366,15 @@ export class PrologueVillageScene extends Phaser.Scene {
: null;
const target = this.nearestInteractionTarget(promptRadius);
const dialogueBounds = this.dialoguePanel?.visible
? this.boundsSnapshot(this.dialoguePanel.getBounds())
? this.boundsSnapshot(
new Phaser.Geom.Rectangle(0, 0, sceneWidth, sceneHeight)
)
: null;
return {
scene: this.scene.key,
ready: this.ready,
visualMotionReduced: this.visualMotionReduced,
viewport: { width: this.scale.width, height: this.scale.height },
campaignStep: campaign.step,
background: {
@@ -380,6 +395,7 @@ export class PrologueVillageScene extends Phaser.Scene {
moving: this.playerMoving,
textureKey: this.player?.texture.key ?? null,
animationKey: this.player?.anims.currentAnim?.key ?? null,
animationPlaying: this.player?.anims.isPlaying ?? false,
bounds: this.player ? this.boundsSnapshot(this.player.getBounds()) : null
}
: null,
@@ -387,6 +403,7 @@ export class PrologueVillageScene extends Phaser.Scene {
speed: playerSpeed,
target: this.moveTarget ? { x: this.moveTarget.x, y: this.moveTarget.y } : null,
targetNpcId: this.moveTargetNpcId ?? null,
lastTargetNpcId: this.lastNavigationTargetId ?? null,
activeWaypoint: this.moveWaypoints[0]
? { x: this.moveWaypoints[0].x, y: this.moveWaypoints[0].y }
: null,
@@ -402,6 +419,15 @@ export class PrologueVillageScene extends Phaser.Scene {
deferredForNarrativeMovement: this.hasNarrativeNpcMovement(),
detourCount: this.navigationDetourCount,
replanAttempts: this.navigationReplanAttempts,
autoInteraction: {
pending: this.autoInteractionPending,
targetId: this.autoInteractionPending
? this.moveTargetNpcId ?? null
: null,
triggeredCount: this.autoInteractionTriggeredCount,
lastTriggeredTargetId:
this.lastAutoInteractionTargetId ?? null
},
walkBounds: this.boundsSnapshot(movementBounds),
collisionRadius: playerCollisionRadius
},
@@ -409,7 +435,8 @@ export class PrologueVillageScene extends Phaser.Scene {
movement: ['WASD', '방향키'],
interact: ['E', 'Space', 'Enter'],
pointerMove: true,
carryoverGuardMs: inputCarryoverGuardMs
carryoverGuardMs: inputCarryoverGuardMs,
reducedMotion: this.visualMotionReduced
},
interaction: {
radius: interactionRadius,
@@ -449,6 +476,7 @@ export class PrologueVillageScene extends Phaser.Scene {
moving: this.npcMoveTweens.has(definition.id),
textureKey: sprite.texture.key,
animationKey: sprite.anims.currentAnim?.key ?? null,
animationPlaying: sprite.anims.isPlaying,
distance: playerPosition ? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y) : null,
completed: definition.objectiveId ? this.isObjectiveComplete(definition.objectiveId) : false,
bounds: this.boundsSnapshot(sprite.getBounds())
@@ -552,6 +580,10 @@ export class PrologueVillageScene extends Phaser.Scene {
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.lastNavigationTargetId = undefined;
this.autoInteractionPending = false;
this.autoInteractionTriggeredCount = 0;
this.lastAutoInteractionTargetId = undefined;
this.queuedMovementIntent = undefined;
this.queuedMovementIntentCount = 0;
this.appliedQueuedMovementIntentCount = 0;
@@ -561,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene {
this.ready = false;
this.navigationPending = false;
this.inputReadyAt = Number.POSITIVE_INFINITY;
this.autoDialogueInputReadyAt = 0;
this.interactionQueued = false;
this.oathGatherPending = false;
this.stepIndex = 0;
@@ -1008,7 +1041,7 @@ export class PrologueVillageScene extends Phaser.Scene {
padding: { left: 11, right: 11, top: 2, bottom: 2 }
}).setOrigin(0.5).setDepth(1201)
: undefined;
if (marker) {
if (marker && !this.visualMotionReduced) {
this.tweens.add({
targets: marker,
y: marker.y - 8,
@@ -1036,14 +1069,16 @@ export class PrologueVillageScene extends Phaser.Scene {
backgroundColor: '#2c2025dd',
padding: { left: 10, right: 10, top: 4, bottom: 4 }
}).setOrigin(0.5).setDepth(1201);
this.tweens.add({
targets: [this.oathMarker, this.oathLabel],
alpha: { from: 0.7, to: 1 },
duration: 900,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
if (!this.visualMotionReduced) {
this.tweens.add({
targets: [this.oathMarker, this.oathLabel],
alpha: { from: 0.7, to: 1 },
duration: 900,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
}
}
private createCharacterSprite(
@@ -1055,9 +1090,13 @@ export class PrologueVillageScene extends Phaser.Scene {
) {
const resolvedTextureKey = this.textures.exists(textureKey) ? textureKey : '__DEFAULT';
const sprite = this.add.sprite(x, y, resolvedTextureKey, 0).setDisplaySize(size, size);
if (this.textures.exists(textureKey)) {
sprite.play(`${textureKey}-idle-${direction}`);
}
applyExplorationCharacterMotion(
sprite,
textureKey,
'idle',
direction,
this.visualMotionReduced
);
return sprite;
}
@@ -1167,6 +1206,9 @@ export class PrologueVillageScene extends Phaser.Scene {
return;
}
if (this.dialogueState) {
if (this.time.now < this.autoDialogueInputReadyAt) {
return;
}
this.advanceDialogue();
return;
}
@@ -1206,18 +1248,27 @@ export class PrologueVillageScene extends Phaser.Scene {
}
let movement = keyboardDirection;
let pointerDistanceRemaining = Number.POSITIVE_INFINITY;
if (movement.lengthSq() > 0) {
this.clearNavigationTarget();
this.clearNavigationTarget(true);
} else if (this.moveTarget) {
const activeTarget = this.moveWaypoints[0] ?? this.moveTarget;
const targetDelta = activeTarget.clone().subtract(
new Phaser.Math.Vector2(this.player.x, this.player.y)
);
if (targetDelta.length() <= navigationArrivalRadius) {
pointerDistanceRemaining = targetDelta.length();
if (pointerDistanceRemaining <= navigationArrivalRadius) {
if (this.moveWaypoints.length > 0) {
this.moveWaypoints.shift();
} else {
this.clearNavigationTarget();
const arrivedTargetId = this.moveTargetNpcId;
const shouldAutoInteract = this.autoInteractionPending;
this.lastNavigationTargetId = arrivedTargetId;
this.clearNavigationTarget(false);
this.setPlayerMoving(false);
if (arrivedTargetId && shouldAutoInteract) {
this.triggerAutoInteraction(arrivedTargetId);
}
}
movement = new Phaser.Math.Vector2();
} else {
@@ -1232,7 +1283,10 @@ export class PrologueVillageScene extends Phaser.Scene {
movement.normalize();
this.playerDirection = this.directionForVector(movement);
const distance = playerSpeed * Math.min(delta, 50) / 1000;
const distance = Math.min(
playerSpeed * Math.min(delta, 50) / 1000,
pointerDistanceRemaining
);
const startX = this.player.x;
const startY = this.player.y;
const nextX = startX + movement.x * distance;
@@ -1249,7 +1303,7 @@ export class PrologueVillageScene extends Phaser.Scene {
}
if (!moved && this.moveTarget) {
if (!this.replanBlockedPointerMovement()) {
this.clearNavigationTarget();
this.clearNavigationTarget(true);
}
}
@@ -1369,12 +1423,12 @@ export class PrologueVillageScene extends Phaser.Scene {
private beginNavigationTo(x: number, y: number, targetNpcId?: string) {
const destination = this.nearestWalkablePoint(x, y);
if (!destination) {
this.clearNavigationTarget();
this.clearNavigationTarget(true);
return;
}
const route = this.planNavigationRoute(destination);
if (!route) {
this.clearNavigationTarget();
this.clearNavigationTarget(true);
return;
}
this.applyNavigationRoute(route, targetNpcId);
@@ -1383,13 +1437,17 @@ export class PrologueVillageScene extends Phaser.Scene {
private applyNavigationRoute(route: Phaser.Math.Vector2[], targetNpcId?: string) {
const destination = route[route.length - 1];
if (!destination) {
this.clearNavigationTarget();
this.clearNavigationTarget(true);
return;
}
this.moveTarget = destination.clone();
this.moveWaypoints = route.slice(0, -1).map((waypoint) => waypoint.clone());
this.moveTargetNpcId = targetNpcId;
this.lastNavigationTargetId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = Boolean(
targetNpcId && targetNpcId !== 'make-oath'
);
if (route.length > 1) {
this.navigationDetourCount += 1;
}
@@ -1615,11 +1673,54 @@ export class PrologueVillageScene extends Phaser.Scene {
return true;
}
private clearNavigationTarget() {
private clearNavigationTarget(clearLast = false) {
this.moveTarget = undefined;
this.moveWaypoints = [];
this.moveTargetNpcId = undefined;
this.navigationReplanAttempts = 0;
this.autoInteractionPending = false;
if (clearLast) {
this.lastNavigationTargetId = undefined;
}
}
private triggerAutoInteraction(targetId: string) {
if (
this.navigationPending ||
this.dialogueState ||
this.oathGatherPending ||
this.hasNarrativeNpcMovement() ||
!this.ready
) {
return;
}
const target = this.interactionTargetById(targetId);
if (!target || target.kind !== 'npc') {
return;
}
if (this.distanceTo(target.x, target.y) > interactionRadius + 8) {
const recoveryRoute = this.approachPositions(target.x, target.y)
.map((destination) => this.planNavigationRoute(destination))
.filter(
(route): route is Phaser.Math.Vector2[] => Boolean(route)
)
.sort(
(left, right) =>
this.navigationRouteLength(left) -
this.navigationRouteLength(right)
)[0];
if (recoveryRoute) {
this.applyNavigationRoute(recoveryRoute, targetId);
}
return;
}
this.autoInteractionTriggeredCount += 1;
this.lastAutoInteractionTargetId = targetId;
this.interactWithTarget(target);
if (this.dialogueState) {
this.autoDialogueInputReadyAt =
this.time.now + inputCarryoverGuardMs;
}
}
private consumeInteractionRequest() {
@@ -1680,10 +1781,13 @@ export class PrologueVillageScene extends Phaser.Scene {
if (!this.player) {
return;
}
const animationKey = `${playerTextureKey}-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
if (this.textures.exists(playerTextureKey) && this.player.anims.currentAnim?.key !== animationKey) {
this.player.play(animationKey);
}
applyExplorationCharacterMotion(
this.player,
playerTextureKey,
moving ? 'walk' : 'idle',
this.playerDirection,
this.visualMotionReduced
);
this.playerMoving = moving;
}
@@ -1874,13 +1978,13 @@ export class PrologueVillageScene extends Phaser.Scene {
moving: boolean,
direction: ExplorationCharacterDirection
) {
if (!this.textures.exists(view.textureKey)) {
return;
}
const animationKey = `${view.textureKey}-${moving ? 'walk' : 'idle'}-${direction}`;
if (view.sprite.anims.currentAnim?.key !== animationKey) {
view.sprite.play(animationKey);
}
applyExplorationCharacterMotion(
view.sprite,
view.textureKey,
moving ? 'walk' : 'idle',
direction,
this.visualMotionReduced
);
}
private safeApproachPosition(targetX: number, targetY: number) {
@@ -2010,8 +2114,8 @@ export class PrologueVillageScene extends Phaser.Scene {
this.dialoguePortraitFrame?.setVisible(true);
this.dialoguePortrait
.setTexture(entry.textureKey)
.setDisplaySize(218, 218)
.setVisible(true);
fitDialogueFacePortrait(this.dialoguePortrait, 218);
this.dialogueNameText?.setX(350);
this.dialogueBodyText?.setX(350).setWordWrapWidth(1382, true);
} else {
@@ -2132,12 +2236,16 @@ export class PrologueVillageScene extends Phaser.Scene {
this.transitionOverlay = this.add.container(0, 0, [shade, title, subtitle])
.setDepth(5000)
.setAlpha(0);
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
if (this.visualMotionReduced) {
this.transitionOverlay.setAlpha(1);
} else {
this.tweens.add({
targets: this.transitionOverlay,
alpha: 1,
duration: 320,
ease: 'Sine.easeOut'
});
}
}
private showCompletionToast(label: string) {
@@ -2150,18 +2258,26 @@ export class PrologueVillageScene extends Phaser.Scene {
color: '#dff0c9',
fontStyle: 'bold'
}).setOrigin(0.5);
this.completionToast = this.add.container(0, 0, [background, text]).setDepth(2200);
this.tweens.add({
targets: this.completionToast,
alpha: 0,
y: -18,
delay: 1150,
duration: 360,
onComplete: () => {
this.completionToast?.destroy();
const toast = this.add.container(0, 0, [background, text]).setDepth(2200);
this.completionToast = toast;
const clearToast = () => {
toast.destroy();
if (this.completionToast === toast) {
this.completionToast = undefined;
}
});
};
if (this.visualMotionReduced) {
this.time.delayedCall(1510, clearToast);
} else {
this.tweens.add({
targets: toast,
alpha: 0,
y: -18,
delay: 1150,
duration: 360,
onComplete: clearToast
});
}
}
private showPromptMessage(message: string) {
@@ -2332,9 +2448,7 @@ export class PrologueVillageScene extends Phaser.Scene {
this.playerDirection = this.directionForVector(toNpc);
this.setPlayerMoving(false);
const npcDirection = this.directionForVector(toNpc.scale(-1));
if (this.textures.exists(view.textureKey)) {
view.sprite.play(`${view.textureKey}-idle-${npcDirection}`);
}
this.playNpcAnimation(view, false, npcDirection);
}
private isObjectiveComplete(objectiveId: PrologueVillageRequiredObjectiveId) {

View File

@@ -64,6 +64,7 @@ import {
} from '../state/campaignState';
import { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion';
import { fitDialogueFacePortrait } from '../ui/dialoguePortrait';
import { palette } from '../ui/palette';
import { ensureLazyScene, startGameScene } from './lazyScenes';
@@ -780,7 +781,7 @@ export class StoryScene extends Phaser.Scene {
this.portraitFrame.setDepth(dialogDepth + 2);
this.portrait = this.add.image(panelX + ui(86), panelY + panelH / 2, this.pagePortraitTextureKey(this.pages[0]) ?? 'story-fallback');
this.portrait.setDisplaySize(ui(128), ui(128));
fitDialogueFacePortrait(this.portrait, ui(128));
this.portrait.setDepth(dialogDepth + 3);
this.portraitDivider = this.add.rectangle(panelX + ui(170), panelY + ui(30), ui(1), panelH - ui(60), palette.gold, 0.12).setOrigin(0);
@@ -902,7 +903,9 @@ export class StoryScene extends Phaser.Scene {
this.portrait?.setVisible(true);
this.portraitDivider?.setVisible(true);
this.portrait?.setTexture(textureKey);
this.portrait?.setDisplaySize(ui(128), ui(128));
if (this.portrait) {
fitDialogueFacePortrait(this.portrait, ui(128));
}
this.nameText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.nameTopOffset));
this.bodyText?.setPosition(ui(270), this.scale.height - ui(storyDialogLayout.bodyTopOffset));
this.bodyText?.setWordWrapWidth(this.scale.width - ui(374), true);
@@ -1663,7 +1666,7 @@ export class StoryScene extends Phaser.Scene {
if (portraitTexture) {
const portrait = this.add.image(portraitX, portraitY, portraitTexture);
portrait.setDisplaySize(height - ui(18), height - ui(18));
fitDialogueFacePortrait(portrait, height - ui(18));
layer.add(portrait);
} else if (this.textures.exists(profile.unitTextureKey)) {
const sprite = this.add.sprite(portraitX, portraitY + ui(3), profile.unitTextureKey, this.storyUnitFrameIndex(actor.direction ?? 'south'));

View File

@@ -0,0 +1,76 @@
import type Phaser from 'phaser';
const defaultFaceCropRatio = 0.6;
const defaultFaceCropTopRatio = 0.035;
export type DialogueFacePortraitLayout = {
cropX: number;
cropY: number;
cropSize: number;
originX: number;
originY: number;
scale: number;
};
export function calculateDialogueFacePortraitLayout(
sourceWidth: number,
sourceHeight: number,
displaySize: number
): DialogueFacePortraitLayout {
if (
!Number.isFinite(sourceWidth) ||
!Number.isFinite(sourceHeight) ||
!Number.isFinite(displaySize) ||
sourceWidth <= 0 ||
sourceHeight <= 0 ||
displaySize <= 0
) {
throw new RangeError('Dialogue portrait dimensions must be finite positive numbers.');
}
const cropSize = Math.max(
1,
Math.round(
Math.min(sourceWidth, sourceHeight) * defaultFaceCropRatio
)
);
const cropX = Math.round((sourceWidth - cropSize) / 2);
const cropY = Math.round(
Math.min(
Math.max(0, sourceHeight * defaultFaceCropTopRatio),
sourceHeight - cropSize
)
);
return {
cropX,
cropY,
cropSize,
originX: (cropX + cropSize / 2) / sourceWidth,
originY: (cropY + cropSize / 2) / sourceHeight,
scale: displaySize / cropSize
};
}
export function fitDialogueFacePortrait(
portrait: Phaser.GameObjects.Image,
displaySize: number
) {
const layout = calculateDialogueFacePortraitLayout(
portrait.frame.realWidth,
portrait.frame.realHeight,
displaySize
);
portrait
.setCrop(
layout.cropX,
layout.cropY,
layout.cropSize,
layout.cropSize
)
.setOrigin(layout.originX, layout.originY)
.setScale(layout.scale);
return layout;
}