1876 lines
63 KiB
TypeScript
1876 lines
63 KiB
TypeScript
import Phaser from 'phaser';
|
|
import villageBackgroundUrl from '../../assets/images/exploration/prologue-village.webp';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import {
|
|
portraitAssetEntryForStoryContext,
|
|
portraitKeyForSpeaker,
|
|
type PortraitAssetEntry
|
|
} from '../data/portraitAssets';
|
|
import {
|
|
prologueBrotherhoodPages,
|
|
prologueVillageNpcDefinitions,
|
|
prologueVillageObjectiveDefinitions,
|
|
prologueVillageRequiredObjectiveIds,
|
|
type PrologueVillageDialogueLine,
|
|
type PrologueVillageNpcDefinition,
|
|
type PrologueVillageRequiredObjectiveId
|
|
} from '../data/prologueVillage';
|
|
import {
|
|
ensureExplorationCharacterAnimations,
|
|
explorationCharacterAssetInfos,
|
|
explorationCharacterTextureKeyForUnitTextureKey,
|
|
explorationCharacterTextureKeyByUnitTextureKey,
|
|
hasExplorationCharacterAsset,
|
|
releaseExplorationCharacterTextureKeys,
|
|
type ExplorationCharacterDirection,
|
|
type ExplorationCharacterTextureKey
|
|
} from '../data/explorationCharacterAssets';
|
|
import {
|
|
completeCampaignTutorial,
|
|
getCampaignState,
|
|
hasCompletedCampaignTutorial,
|
|
markCampaignStep,
|
|
prologueVillageCampaignTutorialIds,
|
|
type CampaignTutorialId
|
|
} from '../state/campaignState';
|
|
import { releaseTextureSource } from '../render/loaderLifecycle';
|
|
import { palette } from '../ui/palette';
|
|
import { startGameScene } from './lazyScenes';
|
|
|
|
const sceneWidth = 1920;
|
|
const sceneHeight = 1080;
|
|
const mapRight = 1495;
|
|
const movementBounds = new Phaser.Geom.Rectangle(42, 108, 1410, 814);
|
|
const playerSpeed = 300;
|
|
const playerCollisionRadius = 25;
|
|
const interactionRadius = 122;
|
|
const promptRadius = 160;
|
|
const characterDisplaySize = 104;
|
|
const inputCarryoverGuardMs = 320;
|
|
const narrativeNpcSpeed = 300;
|
|
const villageBackgroundKey = 'prologue-village-background';
|
|
const villageDialoguePortraitContext = {
|
|
id: 'prologue-village-dialogue',
|
|
background: 'story-three-heroes'
|
|
} as const;
|
|
const villageDialoguePortraitKeys = [
|
|
'liuBei',
|
|
'guanYu',
|
|
'zhangFei',
|
|
'zhuoRecruitingClerk',
|
|
'zhuoVillager'
|
|
] as const;
|
|
const oathPosition = { x: 780, y: 405 };
|
|
const narrativePositions = {
|
|
tavern: {
|
|
zhangFei: { x: 1000, y: 540, direction: 'east' as const }
|
|
},
|
|
recruitment: {
|
|
guanYu: { x: 850, y: 655, direction: 'east' as const },
|
|
zhangFei: { x: 810, y: 805, direction: 'east' as const }
|
|
},
|
|
oath: {
|
|
guanYu: { x: oathPosition.x - 104, y: oathPosition.y + 32, direction: 'east' as const },
|
|
zhangFei: { x: oathPosition.x + 104, y: oathPosition.y + 32, direction: 'west' as const }
|
|
}
|
|
};
|
|
const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei'];
|
|
const villageNpcTextureKeyById = {
|
|
'recruiting-clerk': 'exploration-zhuo-recruiting-clerk',
|
|
'market-villager': 'exploration-zhuo-villager'
|
|
} as const satisfies Readonly<Record<string, ExplorationCharacterTextureKey>>;
|
|
|
|
function explorationTextureKey(unitTextureKey: string): ExplorationCharacterTextureKey {
|
|
const textureKey = explorationCharacterTextureKeyForUnitTextureKey(unitTextureKey);
|
|
if (!textureKey) {
|
|
throw new Error(`Missing exploration character mapping for ${unitTextureKey}`);
|
|
}
|
|
return textureKey;
|
|
}
|
|
|
|
function explorationTextureKeyForVillageNpc(
|
|
npcId: string,
|
|
fallbackUnitTextureKey: string
|
|
): ExplorationCharacterTextureKey {
|
|
const preferredTextureKey =
|
|
villageNpcTextureKeyById[npcId as keyof typeof villageNpcTextureKeyById];
|
|
return preferredTextureKey && hasExplorationCharacterAsset(preferredTextureKey)
|
|
? preferredTextureKey
|
|
: explorationTextureKey(fallbackUnitTextureKey);
|
|
}
|
|
|
|
const characterTextureKeys = Object.freeze(
|
|
Array.from(new Set<ExplorationCharacterTextureKey>([
|
|
playerTextureKey,
|
|
explorationCharacterTextureKeyByUnitTextureKey['unit-guan-yu'],
|
|
explorationCharacterTextureKeyByUnitTextureKey['unit-zhang-fei'],
|
|
explorationTextureKeyForVillageNpc('recruiting-clerk', 'unit-shu-officer'),
|
|
explorationTextureKeyForVillageNpc('market-villager', 'unit-shu-infantry')
|
|
]))
|
|
);
|
|
|
|
type VillageNpcView = {
|
|
definition: PrologueVillageNpcDefinition;
|
|
textureKey: ExplorationCharacterTextureKey;
|
|
sprite: Phaser.GameObjects.Sprite;
|
|
shadow: Phaser.GameObjects.Ellipse;
|
|
nameplate: Phaser.GameObjects.Text;
|
|
marker?: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type DialogueState = {
|
|
lines: PrologueVillageDialogueLine[];
|
|
lineIndex: number;
|
|
onComplete?: () => void;
|
|
sourceNpcId?: string;
|
|
};
|
|
|
|
type InteractionTarget =
|
|
| { kind: 'npc'; id: string; name: string; x: number; y: number }
|
|
| { kind: 'oath'; id: 'make-oath'; name: string; x: number; y: number };
|
|
|
|
type ObjectiveRowView = {
|
|
id: PrologueVillageRequiredObjectiveId | 'make-oath';
|
|
background: Phaser.GameObjects.Rectangle;
|
|
status: Phaser.GameObjects.Text;
|
|
label: Phaser.GameObjects.Text;
|
|
location: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type NarrativeNpcMove = {
|
|
npcId: string;
|
|
target: {
|
|
x: number;
|
|
y: number;
|
|
direction: ExplorationCharacterDirection;
|
|
};
|
|
};
|
|
|
|
const objectiveTutorialIds: Record<PrologueVillageRequiredObjectiveId, CampaignTutorialId> = {
|
|
'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei,
|
|
'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu,
|
|
'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers
|
|
};
|
|
|
|
function villageDialoguePortraitEntries() {
|
|
return villageDialoguePortraitKeys
|
|
.map((portraitKey) => portraitAssetEntryForStoryContext(
|
|
portraitKey,
|
|
villageDialoguePortraitContext,
|
|
`village-${portraitKey}`
|
|
))
|
|
.filter((entry): entry is PortraitAssetEntry => entry !== undefined);
|
|
}
|
|
|
|
export class PrologueVillageScene extends Phaser.Scene {
|
|
private backgroundImage?: Phaser.GameObjects.Image;
|
|
private backgroundFallback = false;
|
|
private player?: Phaser.GameObjects.Sprite;
|
|
private playerShadow?: Phaser.GameObjects.Ellipse;
|
|
private playerDirection: ExplorationCharacterDirection = 'north';
|
|
private playerMoving = false;
|
|
private npcViews = new Map<string, VillageNpcView>();
|
|
private blockers: Phaser.Geom.Rectangle[] = [];
|
|
private objectiveRows = new Map<string, ObjectiveRowView>();
|
|
private objectiveSummaryText?: Phaser.GameObjects.Text;
|
|
private promptBackground?: Phaser.GameObjects.Rectangle;
|
|
private promptText?: Phaser.GameObjects.Text;
|
|
private oathMarker?: Phaser.GameObjects.Text;
|
|
private oathLabel?: Phaser.GameObjects.Text;
|
|
private dialoguePanel?: Phaser.GameObjects.Container;
|
|
private dialoguePortrait?: Phaser.GameObjects.Image;
|
|
private dialogueNameText?: Phaser.GameObjects.Text;
|
|
private dialogueBodyText?: Phaser.GameObjects.Text;
|
|
private dialogueProgressText?: Phaser.GameObjects.Text;
|
|
private dialogueState?: DialogueState;
|
|
private loadingOverlay?: Phaser.GameObjects.Container;
|
|
private transitionOverlay?: Phaser.GameObjects.Container;
|
|
private completionToast?: Phaser.GameObjects.Container;
|
|
private cursorKeys?: Phaser.Types.Input.Keyboard.CursorKeys;
|
|
private moveKeys?: {
|
|
up: Phaser.Input.Keyboard.Key;
|
|
down: Phaser.Input.Keyboard.Key;
|
|
left: Phaser.Input.Keyboard.Key;
|
|
right: Phaser.Input.Keyboard.Key;
|
|
};
|
|
private interactKeys: Phaser.Input.Keyboard.Key[] = [];
|
|
private moveTarget?: Phaser.Math.Vector2;
|
|
private npcMoveTweens = new Map<string, Phaser.Tweens.Tween>();
|
|
private oathGatherPending = false;
|
|
private ready = false;
|
|
private navigationPending = false;
|
|
private inputReadyAt = Number.POSITIVE_INFINITY;
|
|
private interactionQueued = false;
|
|
private stepIndex = 0;
|
|
private lastStepAt = 0;
|
|
private firstVisit = false;
|
|
private ownedPresentationTextureKeys = new Set<string>();
|
|
|
|
constructor() {
|
|
super('PrologueVillageScene');
|
|
}
|
|
|
|
preload() {
|
|
explorationCharacterAssetInfos(characterTextureKeys).forEach((asset) => {
|
|
if (!this.textures.exists(asset.key)) {
|
|
this.load.spritesheet(asset.key, asset.url, {
|
|
frameWidth: asset.frameWidth,
|
|
frameHeight: asset.frameHeight
|
|
});
|
|
}
|
|
});
|
|
if (!this.textures.exists(villageBackgroundKey)) {
|
|
this.ownedPresentationTextureKeys.add(villageBackgroundKey);
|
|
this.load.image(villageBackgroundKey, villageBackgroundUrl);
|
|
}
|
|
villageDialoguePortraitEntries().forEach(({ textureKey, url }) => {
|
|
if (this.textures.exists(textureKey)) {
|
|
return;
|
|
}
|
|
this.ownedPresentationTextureKeys.add(textureKey);
|
|
this.load.image(textureKey, url);
|
|
});
|
|
}
|
|
|
|
create() {
|
|
this.resetRuntimeState();
|
|
this.prepareCampaignProgress();
|
|
this.drawVillage();
|
|
this.drawHud();
|
|
this.createLoadingOverlay();
|
|
this.setupInput();
|
|
|
|
soundDirector.playSoundscape({
|
|
musicKey: 'militia-theme',
|
|
ambienceKey: 'mountain-wind-ambience',
|
|
musicVolume: 0.72,
|
|
ambienceVolume: 0.11
|
|
});
|
|
|
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
this.ready = false;
|
|
this.navigationPending = false;
|
|
this.oathGatherPending = false;
|
|
this.moveTarget = undefined;
|
|
this.dialogueState = undefined;
|
|
this.stopAllNpcMovement();
|
|
releaseExplorationCharacterTextureKeys(this, characterTextureKeys);
|
|
this.releasePresentationTextures();
|
|
});
|
|
|
|
ensureExplorationCharacterAnimations(this, characterTextureKeys);
|
|
this.createActors();
|
|
this.restoreNarrativeActorPositions();
|
|
this.createDialoguePanel();
|
|
this.loadingOverlay?.destroy();
|
|
this.loadingOverlay = undefined;
|
|
this.ready = true;
|
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
|
this.refreshObjectiveHud();
|
|
this.refreshNpcMarkers();
|
|
this.refreshInteractionPrompt();
|
|
|
|
if (this.firstVisit) {
|
|
this.startDialogue([
|
|
{
|
|
speaker: '유비',
|
|
textureKey: 'unit-liu-bei',
|
|
text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.'
|
|
}
|
|
]);
|
|
}
|
|
}
|
|
|
|
update(time: number, delta: number) {
|
|
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
|
|
this.interactionQueued = false;
|
|
return;
|
|
}
|
|
|
|
if (this.oathGatherPending) {
|
|
this.interactionQueued = false;
|
|
this.stopPlayerMovement();
|
|
return;
|
|
}
|
|
|
|
if (this.dialogueState) {
|
|
this.stopPlayerMovement();
|
|
if (this.consumeInteractionRequest()) {
|
|
this.advanceDialogue();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.consumeInteractionRequest()) {
|
|
this.tryInteract();
|
|
return;
|
|
}
|
|
|
|
this.updateMovement(time, delta);
|
|
this.refreshInteractionPrompt();
|
|
}
|
|
|
|
getDebugState() {
|
|
const campaign = getCampaignState();
|
|
const playerPosition = this.player
|
|
? { x: this.player.x, y: this.player.y }
|
|
: null;
|
|
const target = this.nearestInteractionTarget(promptRadius);
|
|
const dialogueBounds = this.dialoguePanel?.visible
|
|
? this.boundsSnapshot(this.dialoguePanel.getBounds())
|
|
: null;
|
|
|
|
return {
|
|
scene: this.scene.key,
|
|
ready: this.ready,
|
|
viewport: { width: this.scale.width, height: this.scale.height },
|
|
campaignStep: campaign.step,
|
|
background: {
|
|
key: this.backgroundImage?.texture.key ?? villageBackgroundKey,
|
|
ready: this.backgroundImage?.active === true,
|
|
fallback: this.backgroundFallback,
|
|
sourceWidth: this.backgroundImage?.frame.realWidth ?? null,
|
|
sourceHeight: this.backgroundImage?.frame.realHeight ?? null,
|
|
bounds: this.backgroundImage
|
|
? this.boundsSnapshot(this.backgroundImage.getBounds())
|
|
: null,
|
|
objectCount: this.backgroundImage?.active ? 1 : 0
|
|
},
|
|
player: playerPosition
|
|
? {
|
|
...playerPosition,
|
|
direction: this.playerDirection,
|
|
moving: this.playerMoving,
|
|
textureKey: this.player?.texture.key ?? null,
|
|
animationKey: this.player?.anims.currentAnim?.key ?? null,
|
|
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,
|
|
walkBounds: this.boundsSnapshot(movementBounds),
|
|
collisionRadius: playerCollisionRadius
|
|
},
|
|
controls: {
|
|
movement: ['WASD', '방향키'],
|
|
interact: ['E', 'Space', 'Enter'],
|
|
pointerMove: true,
|
|
carryoverGuardMs: inputCarryoverGuardMs
|
|
},
|
|
interaction: {
|
|
radius: interactionRadius,
|
|
promptRadius,
|
|
targetId: target?.id ?? null,
|
|
canInteract: Boolean(target && this.distanceTo(target.x, target.y) <= interactionRadius),
|
|
promptVisible: this.promptBackground?.visible ?? false,
|
|
promptBounds: this.promptBackground?.visible
|
|
? this.boundsSnapshot(this.promptBackground.getBounds())
|
|
: null
|
|
},
|
|
objectives: prologueVillageObjectiveDefinitions.map((objective) => ({
|
|
id: objective.id,
|
|
label: objective.label,
|
|
location: objective.location,
|
|
completed: objective.id === 'make-oath'
|
|
? hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete)
|
|
: this.isObjectiveComplete(objective.id),
|
|
unlocked: objective.id === 'make-oath'
|
|
? this.allRequiredObjectivesComplete()
|
|
: this.objectiveUnlocked(objective.id)
|
|
})),
|
|
completedObjectiveIds: this.completedRequiredObjectiveIds(),
|
|
exitUnlocked: this.allRequiredObjectivesComplete(),
|
|
oath: {
|
|
x: oathPosition.x,
|
|
y: oathPosition.y,
|
|
visible: this.oathMarker?.visible ?? false,
|
|
completed: hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete)
|
|
},
|
|
npcs: Array.from(this.npcViews.values()).map(({ definition, sprite }) => ({
|
|
id: definition.id,
|
|
name: definition.name,
|
|
objectiveId: definition.objectiveId ?? null,
|
|
x: sprite.x,
|
|
y: sprite.y,
|
|
moving: this.npcMoveTweens.has(definition.id),
|
|
textureKey: sprite.texture.key,
|
|
animationKey: sprite.anims.currentAnim?.key ?? null,
|
|
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())
|
|
})),
|
|
dialogue: this.dialogueState
|
|
? {
|
|
active: true,
|
|
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
|
|
lineIndex: this.dialogueState.lineIndex,
|
|
totalLines: this.dialogueState.lines.length,
|
|
sourceNpcId: this.dialogueState.sourceNpcId ?? null,
|
|
portraitTextureKey: this.dialoguePortrait?.visible
|
|
? this.dialoguePortrait.texture.key
|
|
: null,
|
|
portraitVisible: this.dialoguePortrait?.visible ?? false,
|
|
bounds: dialogueBounds
|
|
}
|
|
: {
|
|
active: false,
|
|
speaker: null,
|
|
lineIndex: null,
|
|
totalLines: 0,
|
|
sourceNpcId: null,
|
|
portraitTextureKey: null,
|
|
portraitVisible: false,
|
|
bounds: null
|
|
},
|
|
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
|
|
narrativeMovement: {
|
|
movingNpcIds: Array.from(this.npcMoveTweens.keys()),
|
|
oathGatherPending: this.oathGatherPending
|
|
},
|
|
navigationPending: this.navigationPending,
|
|
requiredTextures: characterTextureKeys.map((key) => ({
|
|
key,
|
|
ready: this.textures.exists(key)
|
|
})),
|
|
requiredTexturesReady: characterTextureKeys.every((key) => this.textures.exists(key))
|
|
};
|
|
}
|
|
|
|
debugTeleportTo(targetId: string) {
|
|
if (!this.player || !this.ready || this.navigationPending) {
|
|
return false;
|
|
}
|
|
const target = this.interactionTargetById(targetId);
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
const candidate = this.safeApproachPosition(target.x, target.y);
|
|
this.setPlayerPosition(candidate.x, candidate.y);
|
|
this.moveTarget = undefined;
|
|
this.refreshInteractionPrompt();
|
|
return true;
|
|
}
|
|
|
|
debugInteractWith(targetId: string) {
|
|
if (!this.debugTeleportTo(targetId)) {
|
|
return false;
|
|
}
|
|
this.interactWithTarget(this.interactionTargetById(targetId)!);
|
|
return true;
|
|
}
|
|
|
|
debugCompleteRequiredObjectives() {
|
|
prologueVillageRequiredObjectiveIds.forEach((objectiveId) => {
|
|
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
|
});
|
|
this.refreshObjectiveHud();
|
|
this.refreshNpcMarkers();
|
|
return this.completedRequiredObjectiveIds();
|
|
}
|
|
|
|
debugCompleteVillage() {
|
|
if (!this.ready || this.navigationPending) {
|
|
return false;
|
|
}
|
|
this.debugCompleteRequiredObjectives();
|
|
this.finishVillage();
|
|
return true;
|
|
}
|
|
|
|
private resetRuntimeState() {
|
|
this.stopAllNpcMovement();
|
|
this.backgroundImage = undefined;
|
|
this.backgroundFallback = false;
|
|
this.player = undefined;
|
|
this.playerShadow = undefined;
|
|
this.playerDirection = 'north';
|
|
this.playerMoving = false;
|
|
this.npcViews.clear();
|
|
this.blockers = [];
|
|
this.objectiveRows.clear();
|
|
this.dialoguePortrait = undefined;
|
|
this.dialogueState = undefined;
|
|
this.moveTarget = undefined;
|
|
this.ready = false;
|
|
this.navigationPending = false;
|
|
this.inputReadyAt = Number.POSITIVE_INFINITY;
|
|
this.interactionQueued = false;
|
|
this.oathGatherPending = false;
|
|
this.stepIndex = 0;
|
|
this.lastStepAt = 0;
|
|
}
|
|
|
|
private prepareCampaignProgress() {
|
|
const campaign = getCampaignState();
|
|
this.firstVisit = !campaign.completedTutorialIds.includes(
|
|
prologueVillageCampaignTutorialIds.entered
|
|
);
|
|
if (campaign.step === 'new' || campaign.step === 'prologue') {
|
|
markCampaignStep('prologue-town');
|
|
}
|
|
completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered);
|
|
}
|
|
|
|
private drawVillage() {
|
|
this.cameras.main.setBackgroundColor('#28372a');
|
|
if (this.textures.exists(villageBackgroundKey)) {
|
|
this.backgroundImage = this.add.image(0, 0, villageBackgroundKey)
|
|
.setOrigin(0)
|
|
.setDepth(-120);
|
|
this.registerVillageCollisions();
|
|
this.drawVillageWayfinding();
|
|
} else {
|
|
this.backgroundFallback = true;
|
|
const graphics = this.add.graphics().setDepth(-100);
|
|
graphics.fillStyle(0x354a34, 1);
|
|
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
|
|
graphics.fillStyle(0x42583a, 1);
|
|
graphics.fillRect(0, 92, mapRight, 868);
|
|
|
|
this.drawGroundTexture(graphics);
|
|
this.drawRoads(graphics);
|
|
this.drawVillageBuildings(graphics);
|
|
this.drawVillageDetails();
|
|
}
|
|
|
|
this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.96)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(mapRight, 0, 3, sceneHeight, palette.gold, 0.72)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.96)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(0, 90, sceneWidth, 2, palette.gold, 0.68)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.94)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(0, 956, mapRight, 2, palette.gold, 0.54)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
}
|
|
|
|
private registerVillageCollisions() {
|
|
[
|
|
[105, 160, 410, 236],
|
|
[990, 148, 390, 226],
|
|
[1120, 665, 310, 215],
|
|
[1270, 414, 170, 112],
|
|
[647, 270, 38, 56],
|
|
[697, 313, 38, 52],
|
|
[832, 291, 38, 54],
|
|
[882, 261, 38, 56]
|
|
].forEach(([x, y, width, height]) => this.addBlocker(x, y, width, height));
|
|
}
|
|
|
|
private drawVillageWayfinding() {
|
|
[
|
|
[310, 350, '탁현 관아'],
|
|
[1184, 350, '장터 주점'],
|
|
[1270, 850, '탁현 모병소'],
|
|
[780, 206, '복숭아 동산'],
|
|
[780, 116, '북문']
|
|
].forEach(([x, y, label]) => {
|
|
this.add.text(Number(x), Number(y), String(label), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#f6e5bc',
|
|
fontStyle: 'bold',
|
|
backgroundColor: '#19140fd4',
|
|
padding: { left: 10, right: 10, top: 4, bottom: 4 }
|
|
}).setOrigin(0.5).setDepth(45);
|
|
});
|
|
}
|
|
|
|
private drawGroundTexture(graphics: Phaser.GameObjects.Graphics) {
|
|
for (let index = 0; index < 150; index += 1) {
|
|
const x = 25 + ((index * 83) % 1435);
|
|
const y = 116 + ((index * 137) % 815);
|
|
const tone = index % 3 === 0 ? 0x55704a : index % 3 === 1 ? 0x2c4430 : 0x6a7447;
|
|
graphics.fillStyle(tone, 0.28);
|
|
graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2);
|
|
}
|
|
}
|
|
|
|
private drawRoads(graphics: Phaser.GameObjects.Graphics) {
|
|
graphics.fillStyle(0xb59362, 1);
|
|
graphics.fillRoundedRect(650, 92, 255, 868, 42);
|
|
graphics.fillRoundedRect(70, 525, 1340, 190, 55);
|
|
graphics.fillRoundedRect(865, 390, 390, 190, 48);
|
|
graphics.fillStyle(0xd0b47c, 0.42);
|
|
graphics.fillRoundedRect(685, 92, 58, 868, 28);
|
|
graphics.fillRoundedRect(72, 566, 1338, 38, 18);
|
|
|
|
graphics.fillStyle(0x8a6c48, 0.44);
|
|
for (let y = 126; y < 930; y += 64) {
|
|
graphics.fillEllipse(804 + (y % 3) * 9, y, 44, 13);
|
|
}
|
|
for (let x = 110; x < 1390; x += 76) {
|
|
graphics.fillEllipse(x, 655 + (x % 4) * 5, 38, 12);
|
|
}
|
|
}
|
|
|
|
private drawVillageBuildings(graphics: Phaser.GameObjects.Graphics) {
|
|
this.drawBuilding(graphics, {
|
|
x: 105,
|
|
y: 160,
|
|
width: 410,
|
|
height: 236,
|
|
wallColor: 0xb99a68,
|
|
roofColor: 0x6d2f26,
|
|
sign: '탁현 관아'
|
|
});
|
|
this.addBlocker(105, 160, 410, 236);
|
|
|
|
this.drawBuilding(graphics, {
|
|
x: 990,
|
|
y: 148,
|
|
width: 390,
|
|
height: 226,
|
|
wallColor: 0xc2a974,
|
|
roofColor: 0x31475a,
|
|
sign: '장터 주점'
|
|
});
|
|
this.addBlocker(990, 148, 390, 226);
|
|
|
|
this.drawBuilding(graphics, {
|
|
x: 1120,
|
|
y: 665,
|
|
width: 310,
|
|
height: 215,
|
|
wallColor: 0xa98d61,
|
|
roofColor: 0x493a32,
|
|
sign: '탁현 모병소'
|
|
});
|
|
this.addBlocker(1120, 665, 310, 215);
|
|
|
|
graphics.fillStyle(0x47362a, 1);
|
|
graphics.fillRect(706, 94, 150, 48);
|
|
graphics.fillStyle(0x252018, 1);
|
|
graphics.fillRect(733, 94, 18, 120);
|
|
graphics.fillRect(812, 94, 18, 120);
|
|
graphics.fillStyle(palette.gold, 0.9);
|
|
graphics.fillRect(710, 140, 142, 5);
|
|
this.add.text(781, 117, '북문', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
color: '#f3dfaa',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5).setDepth(40);
|
|
}
|
|
|
|
private drawBuilding(
|
|
graphics: Phaser.GameObjects.Graphics,
|
|
building: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
wallColor: number;
|
|
roofColor: number;
|
|
sign: string;
|
|
}
|
|
) {
|
|
const { x, y, width, height, wallColor, roofColor, sign } = building;
|
|
graphics.fillStyle(0x15120f, 0.24);
|
|
graphics.fillRoundedRect(x + 16, y + 18, width, height, 14);
|
|
graphics.fillStyle(wallColor, 1);
|
|
graphics.fillRoundedRect(x, y + 58, width, height - 58, 10);
|
|
graphics.lineStyle(3, 0x392a22, 0.8);
|
|
graphics.strokeRoundedRect(x, y + 58, width, height - 58, 10);
|
|
graphics.fillStyle(roofColor, 1);
|
|
graphics.fillPoints([
|
|
new Phaser.Geom.Point(x - 24, y + 72),
|
|
new Phaser.Geom.Point(x + width / 2, y),
|
|
new Phaser.Geom.Point(x + width + 24, y + 72),
|
|
new Phaser.Geom.Point(x + width - 3, y + 100),
|
|
new Phaser.Geom.Point(x + 3, y + 100)
|
|
], true);
|
|
graphics.lineStyle(5, 0x211b19, 0.72);
|
|
graphics.lineBetween(x - 22, y + 72, x + width + 22, y + 72);
|
|
graphics.fillStyle(0x34271f, 1);
|
|
graphics.fillRect(x + width / 2 - 30, y + height - 72, 60, 72);
|
|
graphics.fillStyle(0x16110e, 0.8);
|
|
graphics.fillRect(x + 56, y + 132, 62, 45);
|
|
graphics.fillRect(x + width - 118, y + 132, 62, 45);
|
|
|
|
this.add.text(x + width / 2, y + 104, sign, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '23px',
|
|
color: '#2a2019',
|
|
fontStyle: 'bold',
|
|
backgroundColor: '#dfc792',
|
|
padding: { left: 13, right: 13, top: 6, bottom: 6 }
|
|
}).setOrigin(0.5).setDepth(42);
|
|
}
|
|
|
|
private drawVillageDetails() {
|
|
[
|
|
[585, 180, 0.9],
|
|
[935, 210, 0.85],
|
|
[570, 350, 0.78],
|
|
[930, 345, 0.82],
|
|
[575, 800, 0.9],
|
|
[950, 840, 0.82],
|
|
[95, 820, 0.82],
|
|
[250, 875, 0.75]
|
|
].forEach(([x, y, scale]) => this.drawTree(x, y, scale));
|
|
|
|
const stall = this.add.graphics().setDepth(32);
|
|
stall.fillStyle(0x6a4330, 1);
|
|
stall.fillRect(1280, 442, 150, 78);
|
|
stall.fillStyle(0xd7bc78, 1);
|
|
stall.fillTriangle(1260, 445, 1450, 445, 1355, 394);
|
|
stall.fillStyle(0x982f2c, 0.78);
|
|
stall.fillRect(1278, 430, 154, 18);
|
|
this.addBlocker(1270, 414, 170, 112);
|
|
|
|
const weapons = this.add.graphics().setDepth(34);
|
|
weapons.lineStyle(6, 0x544334, 1);
|
|
weapons.lineBetween(1090, 710, 1090, 828);
|
|
weapons.lineBetween(1062, 808, 1118, 808);
|
|
weapons.lineStyle(4, 0x9eabb5, 1);
|
|
weapons.lineBetween(1070, 710, 1110, 805);
|
|
weapons.lineBetween(1110, 710, 1070, 805);
|
|
|
|
const notice = this.add.graphics().setDepth(34);
|
|
notice.fillStyle(0x4d3826, 1);
|
|
notice.fillRect(264, 412, 12, 92);
|
|
notice.fillRect(440, 412, 12, 92);
|
|
notice.fillRect(250, 402, 216, 18);
|
|
notice.fillStyle(0xe3d4a8, 1);
|
|
notice.fillRect(286, 427, 144, 62);
|
|
notice.lineStyle(2, 0x765e3d, 0.8);
|
|
notice.strokeRect(286, 427, 144, 62);
|
|
this.add.text(358, 457, '의병 모집', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '20px',
|
|
color: '#3c2a1c',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5).setDepth(35);
|
|
|
|
this.add.text(780, 244, '복숭아 동산', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '28px',
|
|
color: '#ffe1df',
|
|
fontStyle: 'bold',
|
|
stroke: '#4b252d',
|
|
strokeThickness: 6
|
|
}).setOrigin(0.5).setDepth(45);
|
|
|
|
const blossomGraphics = this.add.graphics().setDepth(25);
|
|
[
|
|
[665, 280],
|
|
[715, 325],
|
|
[850, 305],
|
|
[900, 260]
|
|
].forEach(([x, y]) => {
|
|
blossomGraphics.fillStyle(0x51352e, 1);
|
|
blossomGraphics.fillRect(x - 5, y, 10, 54);
|
|
blossomGraphics.fillStyle(0xf3b4bd, 0.95);
|
|
blossomGraphics.fillCircle(x - 18, y, 29);
|
|
blossomGraphics.fillCircle(x + 18, y - 5, 31);
|
|
blossomGraphics.fillCircle(x, y - 24, 32);
|
|
blossomGraphics.fillStyle(0xffd0d5, 0.9);
|
|
blossomGraphics.fillCircle(x + 4, y - 4, 22);
|
|
});
|
|
}
|
|
|
|
private drawTree(x: number, y: number, scale: number) {
|
|
const graphics = this.add.graphics().setDepth(20 + y / 100);
|
|
graphics.fillStyle(0x3f2b20, 1);
|
|
graphics.fillRoundedRect(x - 8 * scale, y, 16 * scale, 58 * scale, 5);
|
|
graphics.fillStyle(0x25432c, 1);
|
|
graphics.fillCircle(x - 21 * scale, y - 4 * scale, 37 * scale);
|
|
graphics.fillCircle(x + 22 * scale, y - 9 * scale, 40 * scale);
|
|
graphics.fillStyle(0x3c6740, 1);
|
|
graphics.fillCircle(x, y - 33 * scale, 43 * scale);
|
|
graphics.fillStyle(0x648057, 0.7);
|
|
graphics.fillCircle(x - 9 * scale, y - 44 * scale, 20 * scale);
|
|
this.addBlocker(x - 16 * scale, y + 12 * scale, 32 * scale, 43 * scale);
|
|
}
|
|
|
|
private addBlocker(x: number, y: number, width: number, height: number) {
|
|
this.blockers.push(new Phaser.Geom.Rectangle(x, y, width, height));
|
|
}
|
|
|
|
private drawHud() {
|
|
this.add.text(48, 27, '탁현 · 의병 모집 격문이 붙은 날', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '34px',
|
|
color: '#f4e3b5',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1500);
|
|
this.add.text(650, 36, '유비의 첫걸음', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '20px',
|
|
color: '#b4c0cb'
|
|
}).setDepth(1500);
|
|
|
|
this.add.text(1536, 45, '출전 준비', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '31px',
|
|
color: '#f4e3b5',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1500);
|
|
this.add.text(1538, 92, '마을을 걸어 다니며 사람들과 직접 이야기하세요.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#9fabb8',
|
|
wordWrap: { width: 330 }
|
|
}).setDepth(1500);
|
|
|
|
prologueVillageObjectiveDefinitions.forEach((objective, index) => {
|
|
const y = 168 + index * 154;
|
|
const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, 0x3d4858, 1)
|
|
.setDepth(1500);
|
|
const status = this.add.text(1550, y + 19, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1501);
|
|
const label = this.add.text(1550, y + 48, objective.shortLabel, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#e8dfca',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1501);
|
|
const location = this.add.text(1550, y + 87, objective.location, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: '#9aa3ad'
|
|
}).setDepth(1501);
|
|
this.objectiveRows.set(objective.id, { id: objective.id, background, status, label, location });
|
|
});
|
|
|
|
this.objectiveSummaryText = this.add.text(1538, 810, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#cbd3dc',
|
|
lineSpacing: 8,
|
|
wordWrap: { width: 330 }
|
|
}).setDepth(1501);
|
|
|
|
this.add.text(46, 980, '이동', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#e3e7eb'
|
|
}).setDepth(1502);
|
|
this.add.text(445, 980, '대화', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
this.add.text(445, 1012, '가까이에서 E / Space / Enter', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#e3e7eb'
|
|
}).setDepth(1502);
|
|
|
|
this.promptBackground = this.add.rectangle(1110, 1018, 560, 58, 0x2a2119, 0.98)
|
|
.setStrokeStyle(2, palette.gold, 0.9)
|
|
.setDepth(1510)
|
|
.setVisible(false);
|
|
this.promptText = this.add.text(1110, 1018, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '21px',
|
|
color: '#f6e3af',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5).setDepth(1511).setVisible(false);
|
|
}
|
|
|
|
private createLoadingOverlay() {
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.72).setOrigin(0);
|
|
const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 500, 126, 0x151b24, 0.98)
|
|
.setStrokeStyle(2, palette.gold, 0.8);
|
|
const text = this.add.text(sceneWidth / 2, sceneHeight / 2, '탁현의 사람들을 불러오는 중...', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
color: '#f3dfaa'
|
|
}).setOrigin(0.5);
|
|
this.loadingOverlay = this.add.container(0, 0, [shade, panel, text]).setDepth(4000);
|
|
}
|
|
|
|
private createActors() {
|
|
this.playerShadow = this.add.ellipse(750, 870, 66, 25, 0x07100a, 0.45).setDepth(800);
|
|
this.player = this.createCharacterSprite(playerTextureKey, 750, 850, characterDisplaySize + 8, 'north');
|
|
this.player.setDepth(851);
|
|
|
|
prologueVillageNpcDefinitions.forEach((definition) => {
|
|
const textureKey = explorationTextureKeyForVillageNpc(
|
|
definition.id,
|
|
definition.textureKey
|
|
);
|
|
const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4)
|
|
.setDepth(100 + definition.y);
|
|
const sprite = this.createCharacterSprite(
|
|
textureKey,
|
|
definition.x,
|
|
definition.y,
|
|
characterDisplaySize,
|
|
definition.direction
|
|
);
|
|
sprite.setDepth(110 + definition.y);
|
|
const nameplate = this.add.text(definition.x, definition.y + 63, definition.name, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#f5ead0',
|
|
backgroundColor: '#14181ccc',
|
|
padding: { left: 8, right: 8, top: 4, bottom: 4 }
|
|
}).setOrigin(0.5).setDepth(1200);
|
|
const marker = definition.objectiveId
|
|
? this.add.text(definition.x, definition.y - 72, '!', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '31px',
|
|
color: '#2a2013',
|
|
fontStyle: 'bold',
|
|
backgroundColor: '#e5bd68',
|
|
padding: { left: 11, right: 11, top: 2, bottom: 2 }
|
|
}).setOrigin(0.5).setDepth(1201)
|
|
: undefined;
|
|
if (marker) {
|
|
this.tweens.add({
|
|
targets: marker,
|
|
y: marker.y - 8,
|
|
duration: 720,
|
|
yoyo: true,
|
|
repeat: -1,
|
|
ease: 'Sine.easeInOut'
|
|
});
|
|
}
|
|
this.npcViews.set(definition.id, { definition, textureKey, sprite, shadow, nameplate, marker });
|
|
});
|
|
|
|
this.oathMarker = this.add.text(oathPosition.x, oathPosition.y - 58, '◇', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '35px',
|
|
color: '#88756c',
|
|
fontStyle: 'bold',
|
|
stroke: '#341e27',
|
|
strokeThickness: 5
|
|
}).setOrigin(0.5).setDepth(1202);
|
|
this.oathLabel = this.add.text(oathPosition.x, oathPosition.y + 42, '결의의 자리', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d9c0c1',
|
|
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'
|
|
});
|
|
}
|
|
|
|
private createCharacterSprite(
|
|
textureKey: ExplorationCharacterTextureKey,
|
|
x: number,
|
|
y: number,
|
|
size: number,
|
|
direction: ExplorationCharacterDirection
|
|
) {
|
|
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}`);
|
|
}
|
|
return sprite;
|
|
}
|
|
|
|
private createDialoguePanel() {
|
|
const x = 72;
|
|
const y = 710;
|
|
const width = 1776;
|
|
const height = 304;
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x06080b, 0.44)
|
|
.setOrigin(0)
|
|
.setInteractive();
|
|
const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(3, palette.gold, 0.92);
|
|
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.82)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(1, 0x546174, 0.7);
|
|
const portraitFrame = this.add.rectangle(x + 132, y + 152, 226, 226, 0x0e131a, 0.92)
|
|
.setStrokeStyle(2, 0x9e8350, 0.9);
|
|
const firstPortraitKey = villageDialoguePortraitEntries()
|
|
.find(({ textureKey }) => this.textures.exists(textureKey))
|
|
?.textureKey ?? '__DEFAULT';
|
|
this.dialoguePortrait = this.add.image(x + 132, y + 152, firstPortraitKey)
|
|
.setDisplaySize(218, 218)
|
|
.setVisible(false);
|
|
this.dialogueNameText = this.add.text(x + 278, y + 42, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '29px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
});
|
|
this.dialogueBodyText = this.add.text(x + 278, y + 101, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '27px',
|
|
color: '#f0ede5',
|
|
lineSpacing: 11,
|
|
wordWrap: { width: 1382, useAdvancedWrap: true }
|
|
});
|
|
this.dialogueProgressText = this.add.text(x + width - 34, y + height - 33, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#aeb8c4'
|
|
}).setOrigin(1, 0.5);
|
|
|
|
this.dialoguePanel = this.add.container(0, 0, [
|
|
shade,
|
|
background,
|
|
inner,
|
|
portraitFrame,
|
|
this.dialoguePortrait,
|
|
this.dialogueNameText,
|
|
this.dialogueBodyText,
|
|
this.dialogueProgressText
|
|
]).setDepth(3000).setVisible(false);
|
|
}
|
|
|
|
private setupInput() {
|
|
const keyboard = this.input.keyboard;
|
|
if (keyboard) {
|
|
this.cursorKeys = keyboard.createCursorKeys();
|
|
this.moveKeys = {
|
|
up: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
|
down: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
|
left: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
|
right: keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D)
|
|
};
|
|
this.interactKeys = [
|
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
|
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE),
|
|
keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER)
|
|
];
|
|
this.interactKeys.forEach((key) => {
|
|
key.on('down', () => {
|
|
this.interactionQueued = true;
|
|
});
|
|
});
|
|
keyboard.addCapture([
|
|
Phaser.Input.Keyboard.KeyCodes.UP,
|
|
Phaser.Input.Keyboard.KeyCodes.DOWN,
|
|
Phaser.Input.Keyboard.KeyCodes.LEFT,
|
|
Phaser.Input.Keyboard.KeyCodes.RIGHT,
|
|
Phaser.Input.Keyboard.KeyCodes.SPACE
|
|
]);
|
|
}
|
|
|
|
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
|
if (
|
|
!this.ready ||
|
|
this.navigationPending ||
|
|
this.oathGatherPending ||
|
|
this.time.now < this.inputReadyAt
|
|
) {
|
|
return;
|
|
}
|
|
if (this.dialogueState) {
|
|
this.advanceDialogue();
|
|
return;
|
|
}
|
|
if (pointer.x >= movementBounds.left && pointer.x <= movementBounds.right &&
|
|
pointer.y >= movementBounds.top && pointer.y <= movementBounds.bottom) {
|
|
this.moveTarget = new Phaser.Math.Vector2(pointer.x, pointer.y);
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateMovement(time: number, delta: number) {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
const keyboardDirection = this.keyboardMovementDirection();
|
|
let movement = keyboardDirection;
|
|
if (movement.lengthSq() > 0) {
|
|
this.moveTarget = undefined;
|
|
} else if (this.moveTarget) {
|
|
const targetDelta = this.moveTarget.clone().subtract(new Phaser.Math.Vector2(this.player.x, this.player.y));
|
|
if (targetDelta.length() <= 7) {
|
|
this.moveTarget = undefined;
|
|
movement = new Phaser.Math.Vector2();
|
|
} else {
|
|
movement = targetDelta.normalize();
|
|
}
|
|
}
|
|
|
|
if (movement.lengthSq() <= 0) {
|
|
this.setPlayerMoving(false);
|
|
return;
|
|
}
|
|
|
|
movement.normalize();
|
|
this.playerDirection = this.directionForVector(movement);
|
|
const distance = playerSpeed * Math.min(delta, 50) / 1000;
|
|
const startX = this.player.x;
|
|
const startY = this.player.y;
|
|
const nextX = startX + movement.x * distance;
|
|
const nextY = startY + movement.y * distance;
|
|
|
|
let moved = false;
|
|
if (this.canPlayerOccupy(nextX, startY)) {
|
|
this.player.x = nextX;
|
|
moved = true;
|
|
}
|
|
if (this.canPlayerOccupy(this.player.x, nextY)) {
|
|
this.player.y = nextY;
|
|
moved = true;
|
|
}
|
|
if (!moved && this.moveTarget) {
|
|
this.moveTarget = undefined;
|
|
}
|
|
|
|
this.syncPlayerView();
|
|
this.setPlayerMoving(moved);
|
|
if (moved && time - this.lastStepAt >= 280) {
|
|
this.lastStepAt = time;
|
|
this.stepIndex += 1;
|
|
soundDirector.playMovementStep(false, this.stepIndex, {
|
|
terrain: 'plain',
|
|
volume: 0.09,
|
|
rate: 1.04,
|
|
stopAfterMs: 620
|
|
});
|
|
}
|
|
}
|
|
|
|
private consumeInteractionRequest() {
|
|
const keyPressedThisFrame = this.interactKeys.some((key) => Phaser.Input.Keyboard.JustDown(key));
|
|
const requested = this.interactionQueued || keyPressedThisFrame;
|
|
this.interactionQueued = false;
|
|
return requested;
|
|
}
|
|
|
|
private keyboardMovementDirection() {
|
|
const vector = new Phaser.Math.Vector2();
|
|
if (this.cursorKeys?.left.isDown || this.moveKeys?.left.isDown) {
|
|
vector.x -= 1;
|
|
}
|
|
if (this.cursorKeys?.right.isDown || this.moveKeys?.right.isDown) {
|
|
vector.x += 1;
|
|
}
|
|
if (this.cursorKeys?.up.isDown || this.moveKeys?.up.isDown) {
|
|
vector.y -= 1;
|
|
}
|
|
if (this.cursorKeys?.down.isDown || this.moveKeys?.down.isDown) {
|
|
vector.y += 1;
|
|
}
|
|
return vector;
|
|
}
|
|
|
|
private directionForVector(vector: Phaser.Math.Vector2): ExplorationCharacterDirection {
|
|
if (Math.abs(vector.x) > Math.abs(vector.y)) {
|
|
return vector.x > 0 ? 'east' : 'west';
|
|
}
|
|
return vector.y > 0 ? 'south' : 'north';
|
|
}
|
|
|
|
private canPlayerOccupy(x: number, y: number) {
|
|
if (
|
|
x - playerCollisionRadius < movementBounds.left ||
|
|
x + playerCollisionRadius > movementBounds.right ||
|
|
y - playerCollisionRadius < movementBounds.top ||
|
|
y + playerCollisionRadius > movementBounds.bottom
|
|
) {
|
|
return false;
|
|
}
|
|
const feet = new Phaser.Geom.Rectangle(
|
|
x - playerCollisionRadius,
|
|
y + 18,
|
|
playerCollisionRadius * 2,
|
|
28
|
|
);
|
|
if (this.blockers.some((blocker) => Phaser.Geom.Intersects.RectangleToRectangle(feet, blocker))) {
|
|
return false;
|
|
}
|
|
return Array.from(this.npcViews.values()).every(({ sprite }) => (
|
|
Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48
|
|
));
|
|
}
|
|
|
|
private setPlayerMoving(moving: boolean) {
|
|
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);
|
|
}
|
|
this.playerMoving = moving;
|
|
}
|
|
|
|
private stopPlayerMovement() {
|
|
this.moveTarget = undefined;
|
|
this.setPlayerMoving(false);
|
|
}
|
|
|
|
private syncPlayerView() {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
this.playerShadow?.setPosition(this.player.x, this.player.y + 20);
|
|
this.playerShadow?.setDepth(90 + this.player.y);
|
|
this.player.setDepth(100 + this.player.y);
|
|
}
|
|
|
|
private setPlayerPosition(x: number, y: number) {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
this.player.setPosition(x, y);
|
|
this.syncPlayerView();
|
|
this.stopPlayerMovement();
|
|
}
|
|
|
|
private gatherOathParty(onComplete: () => void) {
|
|
if (this.oathGatherPending) {
|
|
return;
|
|
}
|
|
|
|
this.oathGatherPending = true;
|
|
this.stopPlayerMovement();
|
|
this.promptBackground?.setVisible(false);
|
|
this.promptText?.setVisible(false);
|
|
if (this.player) {
|
|
const toOath = new Phaser.Math.Vector2(
|
|
oathPosition.x - this.player.x,
|
|
oathPosition.y - this.player.y
|
|
);
|
|
if (toOath.lengthSq() > 0) {
|
|
this.playerDirection = this.directionForVector(toOath);
|
|
}
|
|
this.setPlayerMoving(false);
|
|
}
|
|
|
|
this.walkNpcGroup([
|
|
{ npcId: 'guan-yu', target: narrativePositions.oath.guanYu },
|
|
{ npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei }
|
|
], () => {
|
|
this.oathGatherPending = false;
|
|
if (!this.scene.isActive() || this.navigationPending) {
|
|
return;
|
|
}
|
|
onComplete();
|
|
});
|
|
}
|
|
|
|
private restoreNarrativeActorPositions() {
|
|
if (this.isObjectiveComplete('register-volunteers')) {
|
|
this.setNpcPosition('guan-yu', narrativePositions.oath.guanYu);
|
|
this.setNpcPosition('zhang-fei', narrativePositions.oath.zhangFei);
|
|
return;
|
|
}
|
|
if (this.isObjectiveComplete('meet-guan-yu')) {
|
|
this.setNpcPosition('guan-yu', narrativePositions.recruitment.guanYu);
|
|
this.setNpcPosition('zhang-fei', narrativePositions.recruitment.zhangFei);
|
|
return;
|
|
}
|
|
if (this.isObjectiveComplete('meet-zhang-fei')) {
|
|
this.setNpcPosition('zhang-fei', narrativePositions.tavern.zhangFei);
|
|
}
|
|
}
|
|
|
|
private setNpcPosition(
|
|
npcId: string,
|
|
target: {
|
|
x: number;
|
|
y: number;
|
|
direction: ExplorationCharacterDirection;
|
|
}
|
|
) {
|
|
const view = this.npcViews.get(npcId);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
this.stopNpcMovement(npcId);
|
|
view.sprite.setPosition(target.x, target.y);
|
|
view.marker?.setVisible(false);
|
|
this.syncNpcView(view);
|
|
this.playNpcAnimation(view, false, target.direction);
|
|
}
|
|
|
|
private walkNpcGroup(moves: NarrativeNpcMove[], onComplete?: () => void) {
|
|
if (!moves.length) {
|
|
onComplete?.();
|
|
return;
|
|
}
|
|
|
|
let remaining = moves.length;
|
|
const completeMove = () => {
|
|
remaining -= 1;
|
|
if (remaining === 0) {
|
|
onComplete?.();
|
|
}
|
|
};
|
|
moves.forEach(({ npcId, target }) => this.walkNpcTo(npcId, target, completeMove));
|
|
}
|
|
|
|
private walkNpcTo(
|
|
npcId: string,
|
|
target: NarrativeNpcMove['target'],
|
|
onComplete?: () => void
|
|
) {
|
|
const view = this.npcViews.get(npcId);
|
|
if (!view) {
|
|
onComplete?.();
|
|
return;
|
|
}
|
|
|
|
this.stopNpcMovement(npcId);
|
|
const movement = new Phaser.Math.Vector2(
|
|
target.x - view.sprite.x,
|
|
target.y - view.sprite.y
|
|
);
|
|
const distance = movement.length();
|
|
view.marker?.setVisible(false);
|
|
if (distance <= 1) {
|
|
view.sprite.setPosition(target.x, target.y);
|
|
this.syncNpcView(view);
|
|
this.playNpcAnimation(view, false, target.direction);
|
|
this.refreshNpcMarkers();
|
|
onComplete?.();
|
|
return;
|
|
}
|
|
|
|
const movementDirection = this.directionForVector(movement);
|
|
this.playNpcAnimation(view, true, movementDirection);
|
|
let tween: Phaser.Tweens.Tween | undefined;
|
|
tween = this.tweens.add({
|
|
targets: view.sprite,
|
|
x: target.x,
|
|
y: target.y,
|
|
duration: Math.max(120, distance / narrativeNpcSpeed * 1000),
|
|
ease: 'Linear',
|
|
onUpdate: () => this.syncNpcView(view),
|
|
onComplete: () => {
|
|
if (!tween || this.npcMoveTweens.get(npcId) !== tween) {
|
|
return;
|
|
}
|
|
this.npcMoveTweens.delete(npcId);
|
|
view.sprite.setPosition(target.x, target.y);
|
|
this.syncNpcView(view);
|
|
this.playNpcAnimation(view, false, target.direction);
|
|
this.refreshNpcMarkers();
|
|
onComplete?.();
|
|
}
|
|
});
|
|
this.npcMoveTweens.set(npcId, tween);
|
|
}
|
|
|
|
private stopNpcMovement(npcId: string) {
|
|
const tween = this.npcMoveTweens.get(npcId);
|
|
if (!tween) {
|
|
return;
|
|
}
|
|
this.npcMoveTweens.delete(npcId);
|
|
tween.stop();
|
|
}
|
|
|
|
private stopAllNpcMovement() {
|
|
Array.from(this.npcMoveTweens.keys()).forEach((npcId) => this.stopNpcMovement(npcId));
|
|
}
|
|
|
|
private syncNpcView(view: VillageNpcView) {
|
|
const { x, y } = view.sprite;
|
|
view.sprite.setDepth(110 + y);
|
|
view.shadow.setPosition(x, y + 20).setDepth(100 + y);
|
|
view.nameplate.setPosition(x, y + 63);
|
|
if (view.marker && !view.marker.visible) {
|
|
view.marker.setPosition(x, y - 72);
|
|
}
|
|
}
|
|
|
|
private playNpcAnimation(
|
|
view: VillageNpcView,
|
|
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);
|
|
}
|
|
}
|
|
|
|
private safeApproachPosition(targetX: number, targetY: number) {
|
|
const candidates = [
|
|
{ x: targetX, y: targetY + 88 },
|
|
{ x: targetX - 88, y: targetY },
|
|
{ x: targetX + 88, y: targetY },
|
|
{ x: targetX, y: targetY - 88 }
|
|
];
|
|
return candidates.find((candidate) => this.canPlayerOccupy(candidate.x, candidate.y)) ??
|
|
{ x: Phaser.Math.Clamp(targetX, movementBounds.left + 40, movementBounds.right - 40),
|
|
y: Phaser.Math.Clamp(targetY + 100, movementBounds.top + 40, movementBounds.bottom - 40) };
|
|
}
|
|
|
|
private tryInteract() {
|
|
const target = this.nearestInteractionTarget(interactionRadius);
|
|
if (!target) {
|
|
this.showPromptMessage('조금 더 가까이 다가가세요.');
|
|
return;
|
|
}
|
|
this.interactWithTarget(target);
|
|
}
|
|
|
|
private interactWithTarget(target: InteractionTarget) {
|
|
this.stopPlayerMovement();
|
|
if (target.kind === 'oath') {
|
|
this.interactWithOath();
|
|
return;
|
|
}
|
|
|
|
const view = this.npcViews.get(target.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
this.faceCharactersForConversation(view);
|
|
const completed = view.definition.objectiveId
|
|
? this.isObjectiveComplete(view.definition.objectiveId)
|
|
: false;
|
|
const unlocked = !view.definition.objectiveId || this.objectiveUnlocked(view.definition.objectiveId);
|
|
const dialogue = !unlocked && view.definition.lockedDialogue?.length
|
|
? view.definition.lockedDialogue
|
|
: completed && view.definition.repeatDialogue?.length
|
|
? view.definition.repeatDialogue
|
|
: view.definition.dialogue;
|
|
this.startDialogue(dialogue, () => {
|
|
if (view.definition.objectiveId && unlocked && !completed) {
|
|
this.completeObjective(view.definition.objectiveId);
|
|
}
|
|
}, view.definition.id);
|
|
}
|
|
|
|
private interactWithOath() {
|
|
if (!this.allRequiredObjectivesComplete()) {
|
|
const remaining = prologueVillageObjectiveDefinitions
|
|
.filter((objective) => objective.id !== 'make-oath' && !this.isObjectiveComplete(objective.id))
|
|
.map((objective) => objective.shortLabel)
|
|
.join(' · ');
|
|
this.startDialogue([
|
|
{
|
|
speaker: '유비',
|
|
textureKey: 'unit-liu-bei',
|
|
text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.`
|
|
}
|
|
]);
|
|
return;
|
|
}
|
|
|
|
this.gatherOathParty(() => this.startOathDialogue());
|
|
}
|
|
|
|
private startOathDialogue() {
|
|
this.startDialogue([
|
|
{
|
|
speaker: '장비',
|
|
textureKey: 'unit-zhang-fei',
|
|
text: '내 장원 뒤 복숭아꽃이 한창이오. 싸움에 앞서 서로의 등을 맡길 뜻부터 하늘과 땅에 고합시다.'
|
|
},
|
|
{
|
|
speaker: '유비',
|
|
textureKey: 'unit-liu-bei',
|
|
text: '좋소. 성은 서로 다르지만 백성을 구하려는 뜻은 하나이니, 오늘부터 형제가 되어 힘을 합칩시다.'
|
|
},
|
|
{
|
|
speaker: '관우',
|
|
textureKey: 'unit-guan-yu',
|
|
text: '의를 저버리지 않고 형제를 저버리지 않겠습니다. 어려움이 닥쳐도 함께 맞서겠습니다.'
|
|
},
|
|
{
|
|
speaker: '장비',
|
|
textureKey: 'unit-zhang-fei',
|
|
text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!'
|
|
}
|
|
], () => this.finishVillage(), 'make-oath');
|
|
}
|
|
|
|
private startDialogue(
|
|
lines: PrologueVillageDialogueLine[],
|
|
onComplete?: () => void,
|
|
sourceNpcId?: string
|
|
) {
|
|
if (!lines.length || this.navigationPending) {
|
|
return;
|
|
}
|
|
this.stopPlayerMovement();
|
|
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId };
|
|
this.dialoguePanel?.setVisible(true);
|
|
this.promptBackground?.setVisible(false);
|
|
this.promptText?.setVisible(false);
|
|
soundDirector.playSelect();
|
|
this.renderDialogueLine();
|
|
}
|
|
|
|
private renderDialogueLine() {
|
|
const dialogue = this.dialogueState;
|
|
if (!dialogue) {
|
|
return;
|
|
}
|
|
const line = dialogue.lines[dialogue.lineIndex];
|
|
this.dialogueNameText?.setText(line.speaker);
|
|
this.dialogueBodyText?.setText(line.text);
|
|
this.dialogueProgressText?.setText(
|
|
`${dialogue.lineIndex + 1} / ${dialogue.lines.length} E · Space · 클릭으로 계속`
|
|
);
|
|
if (this.dialoguePortrait) {
|
|
const entry = this.dialoguePortraitEntry(line);
|
|
if (entry && this.textures.exists(entry.textureKey)) {
|
|
this.dialoguePortrait
|
|
.setTexture(entry.textureKey)
|
|
.setDisplaySize(218, 218)
|
|
.setVisible(true);
|
|
} else {
|
|
this.dialoguePortrait.setVisible(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private dialoguePortraitEntry(line: PrologueVillageDialogueLine) {
|
|
const portraitKey = line.portrait ?? portraitKeyForSpeaker(line.speaker);
|
|
return portraitKey
|
|
? portraitAssetEntryForStoryContext(
|
|
portraitKey,
|
|
villageDialoguePortraitContext,
|
|
`village-dialogue-${line.speaker}`
|
|
)
|
|
: undefined;
|
|
}
|
|
|
|
private releasePresentationTextures() {
|
|
this.ownedPresentationTextureKeys.forEach((textureKey) => {
|
|
releaseTextureSource(this, textureKey);
|
|
});
|
|
this.ownedPresentationTextureKeys.clear();
|
|
}
|
|
|
|
private advanceDialogue() {
|
|
const dialogue = this.dialogueState;
|
|
if (!dialogue || this.navigationPending) {
|
|
return;
|
|
}
|
|
if (dialogue.lineIndex < dialogue.lines.length - 1) {
|
|
dialogue.lineIndex += 1;
|
|
soundDirector.playStoryAdvanceCue();
|
|
this.renderDialogueLine();
|
|
return;
|
|
}
|
|
|
|
const onComplete = dialogue.onComplete;
|
|
this.dialogueState = undefined;
|
|
this.dialoguePanel?.setVisible(false);
|
|
this.stopPlayerMovement();
|
|
onComplete?.();
|
|
this.refreshInteractionPrompt();
|
|
}
|
|
|
|
private completeObjective(objectiveId: PrologueVillageRequiredObjectiveId) {
|
|
if (this.isObjectiveComplete(objectiveId)) {
|
|
return;
|
|
}
|
|
completeCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
|
if (objectiveId === 'meet-zhang-fei') {
|
|
this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei);
|
|
} else if (objectiveId === 'meet-guan-yu') {
|
|
this.walkNpcGroup([
|
|
{ npcId: 'guan-yu', target: narrativePositions.recruitment.guanYu },
|
|
{ npcId: 'zhang-fei', target: narrativePositions.recruitment.zhangFei }
|
|
]);
|
|
} else if (objectiveId === 'register-volunteers') {
|
|
this.walkNpcGroup([
|
|
{ npcId: 'guan-yu', target: narrativePositions.oath.guanYu },
|
|
{ npcId: 'zhang-fei', target: narrativePositions.oath.zhangFei }
|
|
]);
|
|
}
|
|
soundDirector.playObjectiveAchieved();
|
|
this.refreshObjectiveHud();
|
|
this.refreshNpcMarkers();
|
|
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
|
|
this.showCompletionToast(`${objective?.shortLabel ?? '준비'} 완료`);
|
|
}
|
|
|
|
private finishVillage() {
|
|
if (this.navigationPending) {
|
|
return;
|
|
}
|
|
if (!this.allRequiredObjectivesComplete()) {
|
|
this.interactWithOath();
|
|
return;
|
|
}
|
|
|
|
completeCampaignTutorial(prologueVillageCampaignTutorialIds.complete);
|
|
this.refreshObjectiveHud();
|
|
this.navigationPending = true;
|
|
this.stopPlayerMovement();
|
|
soundDirector.playObjectiveAchieved();
|
|
this.showTransitionOverlay();
|
|
|
|
void startGameScene(this, 'StoryScene', {
|
|
pages: prologueBrotherhoodPages(),
|
|
nextScene: 'PrologueMilitiaCampScene',
|
|
presentationBattleId: 'first-battle-zhuo-commandery',
|
|
presentationStage: 'story'
|
|
}).catch((error) => {
|
|
console.error('Failed to continue from the prologue village.', error);
|
|
this.navigationPending = false;
|
|
this.transitionOverlay?.destroy();
|
|
this.transitionOverlay = undefined;
|
|
this.refreshInteractionPrompt();
|
|
});
|
|
}
|
|
|
|
private showTransitionOverlay() {
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.78).setOrigin(0);
|
|
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, '도원결의 · 세 사람의 맹세', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '44px',
|
|
color: '#f3dda2',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5);
|
|
const subtitle = this.add.text(sceneWidth / 2, sceneHeight / 2 + 44, '맹세를 마친 뒤 의용군 막사에서 첫 출전을 준비합니다.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
color: '#d7dde4'
|
|
}).setOrigin(0.5);
|
|
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'
|
|
});
|
|
}
|
|
|
|
private showCompletionToast(label: string) {
|
|
this.completionToast?.destroy();
|
|
const background = this.add.rectangle(748, 132, 380, 70, 0x17251c, 0.98)
|
|
.setStrokeStyle(2, 0x8fbd72, 0.9);
|
|
const text = this.add.text(748, 132, `✓ ${label}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '24px',
|
|
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();
|
|
this.completionToast = undefined;
|
|
}
|
|
});
|
|
}
|
|
|
|
private showPromptMessage(message: string) {
|
|
this.promptBackground?.setVisible(true);
|
|
this.promptText?.setText(message).setVisible(true);
|
|
this.time.delayedCall(850, () => this.refreshInteractionPrompt());
|
|
}
|
|
|
|
private refreshObjectiveHud() {
|
|
const requiredComplete = this.completedRequiredObjectiveIds();
|
|
prologueVillageObjectiveDefinitions.forEach((objective) => {
|
|
const row = this.objectiveRows.get(objective.id);
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const isOath = objective.id === 'make-oath';
|
|
let completed: boolean;
|
|
if (objective.id === 'make-oath') {
|
|
completed = hasCompletedCampaignTutorial(prologueVillageCampaignTutorialIds.complete);
|
|
} else {
|
|
completed = this.isObjectiveComplete(objective.id);
|
|
}
|
|
const unlocked = isOath
|
|
? this.allRequiredObjectivesComplete()
|
|
: this.objectiveUnlocked(objective.id);
|
|
row.status.setText(completed ? '✓ 완료' : unlocked ? '◆ 진행 가능' : '◇ 준비 후 개방');
|
|
row.status.setColor(completed ? '#a8d58e' : unlocked ? '#e1bc69' : '#7f8994');
|
|
row.label.setColor(completed ? '#b8c4b3' : unlocked ? '#f0e6d2' : '#7f8994');
|
|
row.location.setColor(completed ? '#7f9b78' : unlocked ? '#a4afba' : '#666f79');
|
|
row.background.setStrokeStyle(
|
|
2,
|
|
completed ? 0x628653 : unlocked ? 0x8a7043 : 0x3d4858,
|
|
completed ? 0.9 : 0.75
|
|
);
|
|
});
|
|
|
|
const remaining = prologueVillageRequiredObjectiveIds.length - requiredComplete.length;
|
|
this.objectiveSummaryText?.setText(
|
|
remaining > 0
|
|
? `만남의 흐름 ${requiredComplete.length} / ${prologueVillageRequiredObjectiveIds.length}\n◆ 표시된 다음 인물을 찾아 이야기하세요.`
|
|
: '모든 준비 완료\n복숭아 동산의 빛나는 표식으로 이동하세요.'
|
|
);
|
|
if (this.oathMarker) {
|
|
const unlocked = this.allRequiredObjectivesComplete();
|
|
this.oathMarker.setText(unlocked ? '!' : '◇');
|
|
this.oathMarker.setColor(unlocked ? '#2a2013' : '#88756c');
|
|
this.oathMarker.setBackgroundColor(unlocked ? '#f1c96f' : '');
|
|
this.oathLabel?.setText(unlocked ? '세 사람의 결의' : '결의의 자리');
|
|
this.oathLabel?.setColor(unlocked ? '#ffe7b0' : '#a78f91');
|
|
}
|
|
}
|
|
|
|
private refreshNpcMarkers() {
|
|
this.npcViews.forEach(({ definition, marker }) => {
|
|
if (!definition.objectiveId || !marker) {
|
|
return;
|
|
}
|
|
const completed = this.isObjectiveComplete(definition.objectiveId);
|
|
const unlocked = this.objectiveUnlocked(definition.objectiveId);
|
|
const joinedCompanion = completed &&
|
|
(definition.id === 'zhang-fei' || definition.id === 'guan-yu');
|
|
marker.setVisible(!this.npcMoveTweens.has(definition.id) && !joinedCompanion);
|
|
marker.setText(completed ? '✓' : unlocked ? '!' : '◇');
|
|
marker.setColor(completed ? '#e5f2d5' : unlocked ? '#2a2013' : '#9b8f7d');
|
|
marker.setBackgroundColor(completed ? '#4e7549' : unlocked ? '#e5bd68' : '#343b45');
|
|
});
|
|
}
|
|
|
|
private refreshInteractionPrompt() {
|
|
if (!this.ready || this.dialogueState || this.navigationPending) {
|
|
this.promptBackground?.setVisible(false);
|
|
this.promptText?.setVisible(false);
|
|
return;
|
|
}
|
|
const target = this.nearestInteractionTarget(promptRadius);
|
|
if (!target) {
|
|
this.promptBackground?.setVisible(false);
|
|
this.promptText?.setVisible(false);
|
|
return;
|
|
}
|
|
const closeEnough = this.distanceTo(target.x, target.y) <= interactionRadius;
|
|
this.promptBackground?.setVisible(true);
|
|
this.promptText
|
|
?.setText(closeEnough ? `E / Space ${target.name}` : `${target.name}에게 가까이 가세요`)
|
|
.setColor(closeEnough ? '#f6e3af' : '#b2bbc4')
|
|
.setVisible(true);
|
|
}
|
|
|
|
private nearestInteractionTarget(radius: number) {
|
|
if (!this.player) {
|
|
return undefined;
|
|
}
|
|
const targets: InteractionTarget[] = [
|
|
...Array.from(this.npcViews.values())
|
|
.filter(({ definition }) => !this.npcMoveTweens.has(definition.id))
|
|
.map(({ definition, sprite }) => ({
|
|
kind: 'npc' as const,
|
|
id: definition.id,
|
|
name: definition.name,
|
|
x: sprite.x,
|
|
y: sprite.y
|
|
})),
|
|
{
|
|
kind: 'oath' as const,
|
|
id: 'make-oath' as const,
|
|
name: this.allRequiredObjectivesComplete() ? '세 사람의 결의' : '결의의 자리',
|
|
x: oathPosition.x,
|
|
y: oathPosition.y
|
|
}
|
|
];
|
|
return targets
|
|
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
|
|
.filter(({ distance }) => distance <= radius)
|
|
.sort((left, right) => left.distance - right.distance)[0]?.target;
|
|
}
|
|
|
|
private interactionTargetById(targetId: string): InteractionTarget | undefined {
|
|
if (targetId === 'make-oath') {
|
|
return {
|
|
kind: 'oath',
|
|
id: 'make-oath',
|
|
name: '세 사람의 결의',
|
|
x: oathPosition.x,
|
|
y: oathPosition.y
|
|
};
|
|
}
|
|
const view = this.npcViews.get(targetId);
|
|
return view
|
|
? {
|
|
kind: 'npc',
|
|
id: view.definition.id,
|
|
name: view.definition.name,
|
|
x: view.sprite.x,
|
|
y: view.sprite.y
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
private faceCharactersForConversation(view: VillageNpcView) {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
const toNpc = new Phaser.Math.Vector2(view.sprite.x - this.player.x, view.sprite.y - this.player.y);
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
private isObjectiveComplete(objectiveId: PrologueVillageRequiredObjectiveId) {
|
|
return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]);
|
|
}
|
|
|
|
private objectiveUnlocked(objectiveId: PrologueVillageRequiredObjectiveId | 'make-oath') {
|
|
if (objectiveId === 'make-oath') {
|
|
return this.allRequiredObjectivesComplete();
|
|
}
|
|
const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId);
|
|
return !objective?.prerequisiteId || this.isObjectiveComplete(objective.prerequisiteId);
|
|
}
|
|
|
|
private completedRequiredObjectiveIds() {
|
|
return prologueVillageRequiredObjectiveIds.filter((objectiveId) => this.isObjectiveComplete(objectiveId));
|
|
}
|
|
|
|
private allRequiredObjectivesComplete() {
|
|
return this.completedRequiredObjectiveIds().length === prologueVillageRequiredObjectiveIds.length;
|
|
}
|
|
|
|
private distanceTo(x: number, y: number) {
|
|
return this.player ? Phaser.Math.Distance.Between(this.player.x, this.player.y, x, y) : Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
private boundsSnapshot(bounds: Phaser.Geom.Rectangle) {
|
|
return {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
};
|
|
}
|
|
}
|