feat: refine exploration presentation and audio flow
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
76
src/game/ui/dialoguePortrait.ts
Normal file
76
src/game/ui/dialoguePortrait.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user