1882 lines
64 KiB
TypeScript
1882 lines
64 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { soundDirector } from '../audio/SoundDirector';
|
|
import {
|
|
cityStayExplorationProfiles,
|
|
getCityStayExplorationProfile,
|
|
type CityStayExplorationActor,
|
|
type CityStayExplorationProfile,
|
|
type CityStayExplorationTargetKind
|
|
} from '../data/cityStayExploration';
|
|
import {
|
|
cityStayDefinitions,
|
|
findCityStayAfterBattle,
|
|
type CityEquipmentOffer,
|
|
type CityResonanceDialogueChoice,
|
|
type CityStayDefinition,
|
|
type CityStayId
|
|
} from '../data/cityStays';
|
|
import type { BattleScenarioId } from '../data/battles';
|
|
import {
|
|
equipmentSlotLabels,
|
|
getItem,
|
|
itemInventoryLabel
|
|
} from '../data/battleItems';
|
|
import {
|
|
ensureUnitAnimations,
|
|
loadUnitBaseSheets,
|
|
releaseUnitBaseSheetTextures,
|
|
type UnitDirection
|
|
} from '../data/unitAssets';
|
|
import { ExplorationInputController } from '../input/ExplorationInputController';
|
|
import {
|
|
chooseCityStayResonance,
|
|
collectCityStayInformation,
|
|
purchaseCityStayEquipment
|
|
} from '../state/cityStayActions';
|
|
import {
|
|
getCampaignState,
|
|
setActiveCityStayId,
|
|
type CampaignState
|
|
} from '../state/campaignState';
|
|
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 = 164;
|
|
const characterDisplaySize = 104;
|
|
const inputCarryoverGuardMs = 320;
|
|
|
|
type CityStaySceneData = {
|
|
cityStayId?: CityStayId;
|
|
};
|
|
|
|
type CityActorView = {
|
|
definition: CityStayExplorationActor;
|
|
sprite: Phaser.GameObjects.Sprite;
|
|
shadow: Phaser.GameObjects.Ellipse;
|
|
nameplate: Phaser.GameObjects.Text;
|
|
marker: Phaser.GameObjects.Text;
|
|
};
|
|
|
|
type CityInteractionTarget =
|
|
| {
|
|
kind: Exclude<CityStayExplorationTargetKind, 'exit'>;
|
|
id: string;
|
|
name: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
| {
|
|
kind: 'exit';
|
|
id: 'camp-exit';
|
|
name: string;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
type CityDialogueLine = {
|
|
speaker: string;
|
|
text: string;
|
|
textureKey?: string;
|
|
};
|
|
|
|
type DialogueState = {
|
|
lines: CityDialogueLine[];
|
|
lineIndex: number;
|
|
onComplete?: () => void;
|
|
sourceTargetId?: string;
|
|
};
|
|
|
|
type ShopOfferView = {
|
|
offerId: string;
|
|
button: Phaser.GameObjects.Rectangle;
|
|
interactive: boolean;
|
|
};
|
|
|
|
type ChoiceView = {
|
|
choiceId: string;
|
|
button: Phaser.GameObjects.Rectangle;
|
|
interactive: boolean;
|
|
};
|
|
|
|
const cityFallback = cityStayDefinitions[0];
|
|
|
|
export class CityStayScene extends Phaser.Scene {
|
|
private cityStay: CityStayDefinition = cityFallback;
|
|
private profile: CityStayExplorationProfile = cityStayExplorationProfiles[cityFallback.id];
|
|
private campaign?: CampaignState;
|
|
private player?: Phaser.GameObjects.Sprite;
|
|
private playerShadow?: Phaser.GameObjects.Ellipse;
|
|
private playerNameplate?: Phaser.GameObjects.Text;
|
|
private playerDirection: UnitDirection = 'north';
|
|
private playerMoving = false;
|
|
private actors = new Map<string, CityActorView>();
|
|
private blockers: Phaser.Geom.Rectangle[] = [];
|
|
private promptBackground?: Phaser.GameObjects.Rectangle;
|
|
private promptText?: Phaser.GameObjects.Text;
|
|
private progressText?: Phaser.GameObjects.Text;
|
|
private informationStatus?: Phaser.GameObjects.Text;
|
|
private dialogueStatus?: Phaser.GameObjects.Text;
|
|
private informationCard?: Phaser.GameObjects.Rectangle;
|
|
private dialogueCard?: Phaser.GameObjects.Rectangle;
|
|
private dialoguePanel?: Phaser.GameObjects.Container;
|
|
private dialogueAvatar?: Phaser.GameObjects.Sprite;
|
|
private dialogueNameText?: Phaser.GameObjects.Text;
|
|
private dialogueBodyText?: Phaser.GameObjects.Text;
|
|
private dialogueProgressText?: Phaser.GameObjects.Text;
|
|
private dialogueState?: DialogueState;
|
|
private shopPanel?: Phaser.GameObjects.Container;
|
|
private shopOfferViews: ShopOfferView[] = [];
|
|
private shopNotice = '';
|
|
private choicePanel?: Phaser.GameObjects.Container;
|
|
private choiceViews: ChoiceView[] = [];
|
|
private loadingOverlay?: Phaser.GameObjects.Container;
|
|
private transitionOverlay?: Phaser.GameObjects.Container;
|
|
private completionToast?: Phaser.GameObjects.Container;
|
|
private explorationInput?: ExplorationInputController;
|
|
private moveTarget?: Phaser.Math.Vector2;
|
|
private ready = false;
|
|
private navigationPending = false;
|
|
private inputReadyAt = Number.POSITIVE_INFINITY;
|
|
private stepIndex = 0;
|
|
private lastStepAt = 0;
|
|
private lastNotice = '';
|
|
|
|
constructor() {
|
|
super('CityStayScene');
|
|
}
|
|
|
|
init(data?: CityStaySceneData) {
|
|
const campaign = getCampaignState();
|
|
const requested = data?.cityStayId
|
|
? cityStayDefinitions.find((definition) => definition.id === data.cityStayId)
|
|
: undefined;
|
|
const fromCampaign = campaign.latestBattleId
|
|
? findCityStayAfterBattle(campaign.latestBattleId as BattleScenarioId)
|
|
: undefined;
|
|
this.cityStay = requested && (!fromCampaign || requested.id === fromCampaign.id)
|
|
? requested
|
|
: fromCampaign ?? cityFallback;
|
|
this.profile = getCityStayExplorationProfile(this.cityStay.id);
|
|
}
|
|
|
|
create() {
|
|
this.resetRuntimeState();
|
|
this.markCityStayActive();
|
|
this.drawCity();
|
|
this.drawHud();
|
|
this.createLoadingOverlay();
|
|
this.setupInput();
|
|
|
|
soundDirector.playSoundscape({
|
|
musicKey: this.profile.musicKey,
|
|
ambienceKey: this.profile.ambienceKey,
|
|
musicVolume: 0.68,
|
|
ambienceVolume: 0.1
|
|
});
|
|
|
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
this.ready = false;
|
|
this.navigationPending = false;
|
|
this.moveTarget = undefined;
|
|
this.explorationInput?.destroy();
|
|
this.explorationInput = undefined;
|
|
this.dialogueState = undefined;
|
|
releaseUnitBaseSheetTextures(this);
|
|
});
|
|
|
|
const textureKeys = this.characterTextureKeys();
|
|
loadUnitBaseSheets(this, textureKeys, () => {
|
|
if (!this.scene.isActive()) {
|
|
return;
|
|
}
|
|
ensureUnitAnimations(this, textureKeys);
|
|
this.createActors();
|
|
this.createDialoguePanel();
|
|
this.loadingOverlay?.destroy();
|
|
this.loadingOverlay = undefined;
|
|
this.ready = true;
|
|
this.inputReadyAt = this.time.now + inputCarryoverGuardMs;
|
|
this.refreshProgressHud();
|
|
this.refreshActorMarkers();
|
|
this.refreshInteractionPrompt();
|
|
});
|
|
}
|
|
|
|
update(time: number, delta: number) {
|
|
if (!this.ready || this.navigationPending || time < this.inputReadyAt) {
|
|
this.explorationInput?.discardInteractionRequest();
|
|
return;
|
|
}
|
|
|
|
if (this.shopPanel || this.choicePanel) {
|
|
this.stopPlayerMovement();
|
|
this.explorationInput?.discardInteractionRequest();
|
|
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 nearest = this.nearestInteractionTarget(promptRadius);
|
|
const informationComplete = this.informationComplete(campaign);
|
|
const dialogueComplete = this.dialogueComplete(campaign);
|
|
|
|
return {
|
|
scene: this.scene.key,
|
|
ready: this.ready,
|
|
cityStayId: this.cityStay.id,
|
|
cityName: this.cityStay.city.name,
|
|
viewport: { width: this.scale.width, height: this.scale.height },
|
|
activeCityStayId: campaign.activeCityStayId ?? null,
|
|
player: playerPosition
|
|
? {
|
|
...playerPosition,
|
|
direction: this.playerDirection,
|
|
moving: this.playerMoving,
|
|
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,
|
|
bounds: this.boundsSnapshot(movementBounds),
|
|
collisionRadius: playerCollisionRadius
|
|
},
|
|
controls: {
|
|
movement: ['WASD', '방향키'],
|
|
interact: ['E', 'Space', 'Enter'],
|
|
pointerMove: true
|
|
},
|
|
interaction: {
|
|
radius: interactionRadius,
|
|
promptRadius,
|
|
targetId: nearest?.id ?? null,
|
|
targetKind: nearest?.kind ?? null,
|
|
canInteract: Boolean(nearest && this.distanceTo(nearest.x, nearest.y) <= interactionRadius),
|
|
promptVisible: this.promptBackground?.visible ?? false,
|
|
promptBounds: this.promptBackground?.visible
|
|
? this.boundsSnapshot(this.promptBackground.getBounds())
|
|
: null
|
|
},
|
|
progress: {
|
|
completed: Number(informationComplete) + Number(dialogueComplete),
|
|
total: 2,
|
|
informationComplete,
|
|
dialogueComplete,
|
|
marketOptional: true,
|
|
exitUnlocked: true
|
|
},
|
|
actors: Array.from(this.actors.values()).map(({ definition, sprite }) => ({
|
|
id: definition.id,
|
|
kind: definition.kind,
|
|
name: definition.name,
|
|
role: definition.role,
|
|
textureKey: definition.textureKey,
|
|
x: sprite.x,
|
|
y: sprite.y,
|
|
distance: playerPosition
|
|
? Phaser.Math.Distance.Between(playerPosition.x, playerPosition.y, sprite.x, sprite.y)
|
|
: null,
|
|
completed: definition.kind === 'information'
|
|
? informationComplete
|
|
: definition.kind === 'dialogue'
|
|
? dialogueComplete
|
|
: false,
|
|
bounds: this.boundsSnapshot(sprite.getBounds())
|
|
})),
|
|
exit: {
|
|
...this.profile.exit,
|
|
distance: playerPosition
|
|
? Phaser.Math.Distance.Between(
|
|
playerPosition.x,
|
|
playerPosition.y,
|
|
this.profile.exit.x,
|
|
this.profile.exit.y
|
|
)
|
|
: null
|
|
},
|
|
dialogue: this.dialogueState
|
|
? {
|
|
active: true,
|
|
speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '',
|
|
lineIndex: this.dialogueState.lineIndex,
|
|
totalLines: this.dialogueState.lines.length,
|
|
sourceTargetId: this.dialogueState.sourceTargetId ?? null,
|
|
bounds: this.dialoguePanel?.visible
|
|
? this.boundsSnapshot(this.dialoguePanel.getBounds())
|
|
: null
|
|
}
|
|
: {
|
|
active: false,
|
|
speaker: null,
|
|
lineIndex: null,
|
|
totalLines: 0,
|
|
sourceTargetId: null,
|
|
bounds: null
|
|
},
|
|
shop: {
|
|
open: Boolean(this.shopPanel),
|
|
gold: campaign.gold,
|
|
notice: this.shopNotice,
|
|
panelBounds: this.shopPanel ? this.boundsSnapshot(this.shopPanel.getBounds()) : null,
|
|
offers: this.cityStay.equipmentOffers.map((offer) => {
|
|
const item = getItem(offer.itemId);
|
|
const view = this.shopOfferViews.find((candidate) => candidate.offerId === offer.id);
|
|
return {
|
|
id: offer.id,
|
|
itemId: item.id,
|
|
itemName: item.name,
|
|
price: offer.price,
|
|
owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0,
|
|
canBuy: campaign.gold >= offer.price,
|
|
interactive: Boolean(view?.interactive),
|
|
buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
|
|
};
|
|
})
|
|
},
|
|
choice: {
|
|
open: Boolean(this.choicePanel),
|
|
panelBounds: this.choicePanel ? this.boundsSnapshot(this.choicePanel.getBounds()) : null,
|
|
choices: this.cityStay.dialogue.choices.map((choice) => {
|
|
const view = this.choiceViews.find((candidate) => candidate.choiceId === choice.id);
|
|
return {
|
|
id: choice.id,
|
|
label: choice.label,
|
|
rewardExp: this.cityStay.dialogue.rewardExp + choice.rewardExp,
|
|
interactive: Boolean(view?.interactive),
|
|
bounds: view ? this.boundsSnapshot(view.button.getBounds()) : null
|
|
};
|
|
})
|
|
},
|
|
campaign: {
|
|
gold: campaign.gold,
|
|
inventory: { ...campaign.inventory },
|
|
completedCampVisits: [...campaign.completedCampVisits],
|
|
completedCampDialogues: [...campaign.completedCampDialogues],
|
|
campDialogueChoiceIds: { ...campaign.campDialogueChoiceIds }
|
|
},
|
|
blockers: this.blockers.map((blocker) => this.boundsSnapshot(blocker)),
|
|
navigationPending: this.navigationPending,
|
|
lastNotice: this.lastNotice,
|
|
requiredTexturesReady: this.characterTextureKeys().every((key) => this.textures.exists(key))
|
|
};
|
|
}
|
|
|
|
debugTeleportTo(targetIdOrKind: string) {
|
|
if (!this.player || !this.ready || this.navigationPending || this.modalOpen()) {
|
|
return false;
|
|
}
|
|
const target = this.interactionTargetByIdOrKind(targetIdOrKind);
|
|
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(targetIdOrKind: string) {
|
|
if (!this.debugTeleportTo(targetIdOrKind)) {
|
|
return false;
|
|
}
|
|
const target = this.interactionTargetByIdOrKind(targetIdOrKind);
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
this.interactWithTarget(target);
|
|
return true;
|
|
}
|
|
|
|
private resetRuntimeState() {
|
|
this.campaign = getCampaignState();
|
|
this.player = undefined;
|
|
this.playerShadow = undefined;
|
|
this.playerNameplate = undefined;
|
|
this.playerDirection = this.profile.playerStart.direction;
|
|
this.playerMoving = false;
|
|
this.actors.clear();
|
|
this.blockers = [];
|
|
this.dialogueState = undefined;
|
|
this.shopPanel = undefined;
|
|
this.shopOfferViews = [];
|
|
this.shopNotice = '';
|
|
this.choicePanel = undefined;
|
|
this.choiceViews = [];
|
|
this.moveTarget = undefined;
|
|
this.ready = false;
|
|
this.navigationPending = false;
|
|
this.inputReadyAt = Number.POSITIVE_INFINITY;
|
|
this.explorationInput = undefined;
|
|
this.stepIndex = 0;
|
|
this.lastStepAt = 0;
|
|
this.lastNotice = '';
|
|
}
|
|
|
|
private markCityStayActive() {
|
|
this.campaign = setActiveCityStayId(this.cityStay.id);
|
|
}
|
|
|
|
private characterTextureKeys() {
|
|
return [
|
|
...new Set([
|
|
'unit-liu-bei',
|
|
...this.profile.actors.map((actor) => actor.textureKey)
|
|
])
|
|
];
|
|
}
|
|
|
|
private drawCity() {
|
|
const graphics = this.add.graphics().setDepth(-100);
|
|
this.cameras.main.setBackgroundColor(`#${this.profile.groundColor.toString(16).padStart(6, '0')}`);
|
|
graphics.fillStyle(this.profile.groundColor, 1);
|
|
graphics.fillRect(0, 0, sceneWidth, sceneHeight);
|
|
graphics.fillStyle(this.profile.grassColor, 1);
|
|
graphics.fillRect(0, 92, mapRight, 868);
|
|
|
|
this.drawGroundTexture(graphics);
|
|
this.drawRoads(graphics);
|
|
this.profile.buildings.forEach((building) => {
|
|
this.drawBuilding(graphics, building);
|
|
this.addBlocker(building.x, building.y, building.width, building.height);
|
|
});
|
|
this.drawCityDetails();
|
|
this.drawCampGate();
|
|
|
|
this.add.rectangle(mapRight, 0, sceneWidth - mapRight, sceneHeight, 0x121720, 0.97)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(mapRight, 0, 3, sceneHeight, this.profile.accentColor, 0.78)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
this.add.rectangle(0, 0, sceneWidth, 92, 0x111720, 0.97)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(0, 90, sceneWidth, 2, this.profile.accentColor, 0.72)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
this.add.rectangle(0, 958, mapRight, 122, 0x10151c, 0.95)
|
|
.setOrigin(0)
|
|
.setDepth(1400);
|
|
this.add.rectangle(0, 956, mapRight, 2, this.profile.accentColor, 0.58)
|
|
.setOrigin(0)
|
|
.setDepth(1401);
|
|
}
|
|
|
|
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
|
|
? this.profile.groundColor
|
|
: index % 3 === 1
|
|
? this.profile.grassColor
|
|
: this.profile.roadHighlightColor;
|
|
graphics.fillStyle(tone, index % 3 === 2 ? 0.16 : 0.3);
|
|
graphics.fillCircle(x, y, index % 4 === 0 ? 3 : 2);
|
|
}
|
|
}
|
|
|
|
private drawRoads(graphics: Phaser.GameObjects.Graphics) {
|
|
graphics.fillStyle(this.profile.roadColor, 1);
|
|
graphics.fillRoundedRect(650, 92, 255, 868, 42);
|
|
graphics.fillRoundedRect(70, 525, 1340, 190, 55);
|
|
graphics.fillRoundedRect(865, 390, 390, 190, 48);
|
|
graphics.fillStyle(this.profile.roadHighlightColor, 0.38);
|
|
graphics.fillRoundedRect(686, 92, 56, 868, 28);
|
|
graphics.fillRoundedRect(72, 566, 1338, 38, 18);
|
|
|
|
graphics.fillStyle(0x775f43, 0.42);
|
|
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 drawBuilding(
|
|
graphics: Phaser.GameObjects.Graphics,
|
|
building: CityStayExplorationProfile['buildings'][number]
|
|
) {
|
|
const { x, y, width, height, wallColor, roofColor, label } = building;
|
|
graphics.fillStyle(0x15120f, 0.25);
|
|
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.82);
|
|
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.74);
|
|
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, label, {
|
|
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 drawCityDetails() {
|
|
[
|
|
[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(this.profile.roadHighlightColor, 1);
|
|
stall.fillTriangle(1260, 445, 1450, 445, 1355, 394);
|
|
stall.fillStyle(this.profile.accentColor, 0.78);
|
|
stall.fillRect(1278, 430, 154, 18);
|
|
this.addBlocker(1270, 414, 170, 112);
|
|
|
|
const plaza = this.add.graphics().setDepth(26);
|
|
plaza.fillStyle(0x4f5d68, 0.9);
|
|
plaza.fillCircle(780, 405, 42);
|
|
plaza.fillStyle(0x87a0ad, 0.72);
|
|
plaza.fillCircle(780, 405, 28);
|
|
plaza.fillStyle(0xc9d6da, 0.72);
|
|
plaza.fillCircle(780, 398, 11);
|
|
this.addBlocker(750, 390, 60, 38);
|
|
|
|
this.add.text(780, 474, this.profile.landmarkLabel, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#f3dfaa',
|
|
fontStyle: 'bold',
|
|
backgroundColor: '#2c2025cc',
|
|
padding: { left: 12, right: 12, top: 5, bottom: 5 }
|
|
}).setOrigin(0.5).setDepth(45);
|
|
}
|
|
|
|
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 drawCampGate() {
|
|
const gate = this.add.graphics().setDepth(36);
|
|
gate.fillStyle(0x47362a, 1);
|
|
gate.fillRect(695, 874, 170, 48);
|
|
gate.fillStyle(0x252018, 1);
|
|
gate.fillRect(722, 858, 18, 100);
|
|
gate.fillRect(820, 858, 18, 100);
|
|
gate.fillStyle(this.profile.accentColor, 0.92);
|
|
gate.fillRect(700, 920, 160, 5);
|
|
this.add.text(780, 898, '군영 출구', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#f3dfaa',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5).setDepth(40);
|
|
}
|
|
|
|
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, this.profile.heading, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '33px',
|
|
color: '#f4e3b5',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1500);
|
|
this.add.text(495, 35, this.cityStay.city.region, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#b4c0cb'
|
|
}).setDepth(1500);
|
|
|
|
this.add.text(1536, 43, `${this.cityStay.city.name} 체류`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '31px',
|
|
color: '#f4e3b5',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1500);
|
|
this.add.text(1538, 91, this.profile.subtitle, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#9fabb8',
|
|
wordWrap: { width: 330, useAdvancedWrap: true }
|
|
}).setDepth(1500);
|
|
|
|
this.informationCard = this.drawObjectiveCard(
|
|
168,
|
|
'정보 수집',
|
|
this.cityStay.information.location,
|
|
'관청 관리인에게 다가가세요.'
|
|
);
|
|
this.informationStatus = this.add.text(1550, 187, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
|
|
this.drawObjectiveCard(
|
|
322,
|
|
'시장과 대장간',
|
|
`${this.cityStay.equipmentOffers.length}종 일반 장비`,
|
|
'필요한 장비를 원하는 만큼 구매합니다.'
|
|
);
|
|
this.add.text(1550, 341, '◇ 선택 활동', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
|
|
this.dialogueCard = this.drawObjectiveCard(
|
|
476,
|
|
'동료와 대화',
|
|
this.cityDialogueUnitNames(),
|
|
'선택에 따라 공명이 상승합니다.'
|
|
);
|
|
this.dialogueStatus = this.add.text(1550, 495, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
|
|
this.drawObjectiveCard(
|
|
630,
|
|
'군영으로 복귀',
|
|
'남쪽 군영 출구',
|
|
'체류 활동은 선택이며 언제든 돌아갈 수 있습니다.'
|
|
);
|
|
this.add.text(1550, 649, '◆ 항상 가능', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#a8d58e',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
|
|
this.progressText = this.add.text(1538, 811, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#cbd3dc',
|
|
lineSpacing: 8,
|
|
wordWrap: { width: 330 }
|
|
}).setDepth(1502);
|
|
|
|
this.add.text(46, 980, '이동', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#d8b15f',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1502);
|
|
this.add.text(46, 1012, 'WASD / 방향키 · 바닥 클릭', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#e3e7eb'
|
|
}).setDepth(1502);
|
|
this.add.text(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.add.text(1250, 1012, 'ESC · 창 닫기', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#9fabb8'
|
|
}).setOrigin(1, 0).setDepth(1502);
|
|
|
|
this.promptBackground = this.add.rectangle(880, 1018, 570, 58, 0x2a2119, 0.98)
|
|
.setStrokeStyle(2, this.profile.accentColor, 0.92)
|
|
.setDepth(1510)
|
|
.setVisible(false);
|
|
this.promptText = this.add.text(880, 1018, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '21px',
|
|
color: '#f6e3af',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5).setDepth(1511).setVisible(false);
|
|
}
|
|
|
|
private drawObjectiveCard(y: number, title: string, location: string, help: string) {
|
|
const background = this.add.rectangle(1530, y, 350, 132, 0x1b222d, 0.94)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, 0x3d4858, 1)
|
|
.setDepth(1500);
|
|
this.add.text(1550, y + 48, title, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: '#e8dfca',
|
|
fontStyle: 'bold'
|
|
}).setDepth(1501);
|
|
this.add.text(1550, y + 82, location, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '15px',
|
|
color: '#a4afba'
|
|
}).setDepth(1501);
|
|
this.add.text(1550, y + 105, help, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '12px',
|
|
color: '#788491',
|
|
wordWrap: { width: 318 }
|
|
}).setDepth(1501);
|
|
return background;
|
|
}
|
|
|
|
private createLoadingOverlay() {
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.74).setOrigin(0);
|
|
const panel = this.add.rectangle(sceneWidth / 2, sceneHeight / 2, 520, 126, 0x151b24, 0.98)
|
|
.setStrokeStyle(2, this.profile.accentColor, 0.82);
|
|
const text = this.add.text(sceneWidth / 2, sceneHeight / 2, `${this.cityStay.city.name}의 사람들을 불러오는 중...`, {
|
|
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() {
|
|
const start = this.profile.playerStart;
|
|
this.playerShadow = this.add.ellipse(start.x, start.y + 20, 66, 25, 0x07100a, 0.45).setDepth(800);
|
|
this.player = this.createCharacterSprite('unit-liu-bei', start.x, start.y, characterDisplaySize + 8, start.direction);
|
|
this.player.setDepth(100 + start.y);
|
|
this.playerNameplate = this.add.text(start.x, start.y + 66, '유비', {
|
|
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);
|
|
|
|
this.profile.actors.forEach((definition) => {
|
|
const shadow = this.add.ellipse(definition.x, definition.y + 20, 62, 23, 0x07100a, 0.4)
|
|
.setDepth(100 + definition.y);
|
|
const sprite = this.createCharacterSprite(
|
|
definition.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 = this.add.text(definition.x, definition.y - 72, this.markerText(definition.kind), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: definition.kind === 'market' ? '22px' : '31px',
|
|
color: '#2a2013',
|
|
fontStyle: 'bold',
|
|
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'
|
|
});
|
|
this.actors.set(definition.id, { definition, sprite, shadow, nameplate, marker });
|
|
});
|
|
|
|
const exit = this.profile.exit;
|
|
const exitMarker = this.add.text(exit.x, exit.y - 58, '↩', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '32px',
|
|
color: '#f0d79b',
|
|
fontStyle: 'bold',
|
|
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
|
|
});
|
|
}
|
|
|
|
private createCharacterSprite(
|
|
textureKey: string,
|
|
x: number,
|
|
y: number,
|
|
size: number,
|
|
direction: UnitDirection
|
|
) {
|
|
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.45)
|
|
.setOrigin(0)
|
|
.setInteractive();
|
|
const background = this.add.rectangle(x, y, width, height, 0x141a23, 0.985)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(3, this.profile.accentColor, 0.94);
|
|
const inner = this.add.rectangle(x + 16, y + 16, width - 32, height - 32, 0x1d2531, 0.84)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(1, 0x546174, 0.72);
|
|
const avatarFrame = this.add.rectangle(x + 105, y + 152, 178, 230, 0x0e131a, 0.92)
|
|
.setStrokeStyle(2, 0x9e8350, 0.9);
|
|
this.dialogueAvatar = this.add.sprite(x + 105, y + 155, 'unit-liu-bei', 0)
|
|
.setDisplaySize(186, 186);
|
|
this.dialogueNameText = this.add.text(x + 222, y + 42, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '29px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
});
|
|
this.dialogueBodyText = this.add.text(x + 222, y + 101, '', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '27px',
|
|
color: '#f0ede5',
|
|
lineSpacing: 11,
|
|
wordWrap: { width: 1440, 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,
|
|
avatarFrame,
|
|
this.dialogueAvatar,
|
|
this.dialogueNameText,
|
|
this.dialogueBodyText,
|
|
this.dialogueProgressText
|
|
]).setDepth(3000).setVisible(false);
|
|
}
|
|
|
|
private setupInput() {
|
|
this.explorationInput = new ExplorationInputController(this)
|
|
.bindKey(
|
|
Phaser.Input.Keyboard.KeyCodes.ESC,
|
|
() => this.handleEscape()
|
|
)
|
|
.bindKey(
|
|
Phaser.Input.Keyboard.KeyCodes.ONE,
|
|
() => this.chooseDialogueByIndex(0)
|
|
)
|
|
.bindKey(
|
|
Phaser.Input.Keyboard.KeyCodes.TWO,
|
|
() => this.chooseDialogueByIndex(1)
|
|
);
|
|
|
|
this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => {
|
|
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
|
return;
|
|
}
|
|
if (this.shopPanel || this.choicePanel) {
|
|
return;
|
|
}
|
|
if (this.dialogueState) {
|
|
this.advanceDialogue();
|
|
return;
|
|
}
|
|
if (
|
|
pointer.worldX >= movementBounds.left &&
|
|
pointer.worldX <= movementBounds.right &&
|
|
pointer.worldY >= movementBounds.top &&
|
|
pointer.worldY <= movementBounds.bottom
|
|
) {
|
|
this.moveTarget = new Phaser.Math.Vector2(pointer.worldX, pointer.worldY);
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleEscape() {
|
|
if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) {
|
|
return;
|
|
}
|
|
if (this.shopPanel) {
|
|
this.closeShop();
|
|
return;
|
|
}
|
|
if (this.choicePanel) {
|
|
this.closeChoicePanel();
|
|
return;
|
|
}
|
|
if (this.dialogueState) {
|
|
this.cancelDialogue();
|
|
return;
|
|
}
|
|
this.showPromptMessage('군영으로 돌아가려면 남쪽 군영 출구로 이동하세요.');
|
|
}
|
|
|
|
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: this.profile.movementTerrain,
|
|
volume: 0.085,
|
|
rate: 1.02,
|
|
stopAfterMs: 620
|
|
});
|
|
}
|
|
}
|
|
|
|
private consumeInteractionRequest() {
|
|
return this.explorationInput?.consumeInteractionRequest() ?? false;
|
|
}
|
|
|
|
private keyboardMovementDirection() {
|
|
return (
|
|
this.explorationInput?.movementDirection() ??
|
|
new Phaser.Math.Vector2()
|
|
);
|
|
}
|
|
|
|
private directionForVector(vector: Phaser.Math.Vector2): UnitDirection {
|
|
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.actors.values()).every(({ sprite }) => (
|
|
Phaser.Math.Distance.Between(x, y, sprite.x, sprite.y) >= 48
|
|
));
|
|
}
|
|
|
|
private setPlayerMoving(moving: boolean) {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
const animationKey = `unit-liu-bei-${moving ? 'walk' : 'idle'}-${this.playerDirection}`;
|
|
if (this.textures.exists('unit-liu-bei') && 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);
|
|
this.playerNameplate?.setPosition(this.player.x, this.player.y + 66);
|
|
}
|
|
|
|
private setPlayerPosition(x: number, y: number) {
|
|
if (!this.player) {
|
|
return;
|
|
}
|
|
this.player.setPosition(x, y);
|
|
this.syncPlayerView();
|
|
this.stopPlayerMovement();
|
|
}
|
|
|
|
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: CityInteractionTarget) {
|
|
this.stopPlayerMovement();
|
|
if (target.kind === 'exit') {
|
|
this.interactWithExit();
|
|
return;
|
|
}
|
|
const view = this.actors.get(target.id);
|
|
if (!view) {
|
|
return;
|
|
}
|
|
this.faceCharactersForConversation(view);
|
|
if (target.kind === 'information') {
|
|
this.interactWithInformation(view);
|
|
return;
|
|
}
|
|
if (target.kind === 'market') {
|
|
this.openShop();
|
|
return;
|
|
}
|
|
this.interactWithCompanion(view);
|
|
}
|
|
|
|
private interactWithInformation(view: CityActorView) {
|
|
const campaign = getCampaignState();
|
|
const information = this.cityStay.information;
|
|
if (this.informationComplete(campaign)) {
|
|
this.startDialogue([
|
|
{
|
|
speaker: view.definition.name,
|
|
textureKey: view.definition.textureKey,
|
|
text: `${information.title}은 이미 장부에 기록했습니다.`
|
|
},
|
|
...information.briefingLines.map((text) => ({
|
|
speaker: '확보한 정보',
|
|
textureKey: view.definition.textureKey,
|
|
text
|
|
}))
|
|
], undefined, view.definition.id);
|
|
return;
|
|
}
|
|
|
|
this.startDialogue([
|
|
{
|
|
speaker: view.definition.name,
|
|
textureKey: view.definition.textureKey,
|
|
text: information.description
|
|
},
|
|
{
|
|
speaker: '유비',
|
|
textureKey: 'unit-liu-bei',
|
|
text: '현지의 증언과 장부를 함께 대조해 다음 싸움에서 놓칠 길이 없도록 합시다.'
|
|
},
|
|
...information.briefingLines.map((text) => ({
|
|
speaker: view.definition.name,
|
|
textureKey: view.definition.textureKey,
|
|
text
|
|
}))
|
|
], () => this.completeInformation(), view.definition.id);
|
|
}
|
|
|
|
private completeInformation() {
|
|
const result = collectCityStayInformation(
|
|
this.cityStay.id,
|
|
this.cityStay.information.id
|
|
);
|
|
if (!result.ok) {
|
|
const message = result.reason === 'already-completed'
|
|
? '이미 확보한 정보입니다.'
|
|
: '정보를 장부에 기록하지 못했습니다. 저장 상태를 확인해 주세요.';
|
|
this.showCompletionToast(message, false);
|
|
this.refreshProgressHud();
|
|
return;
|
|
}
|
|
this.campaign = result.campaign;
|
|
this.lastNotice = `정보 확보 · ${result.information.title}`;
|
|
soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 });
|
|
this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`);
|
|
this.refreshProgressHud();
|
|
this.refreshActorMarkers();
|
|
}
|
|
|
|
private interactWithCompanion(view: CityActorView) {
|
|
const campaign = getCampaignState();
|
|
const dialogue = this.cityStay.dialogue;
|
|
if (this.dialogueComplete(campaign)) {
|
|
const choiceId = campaign.campDialogueChoiceIds[dialogue.id];
|
|
const choice = dialogue.choices.find((candidate) => candidate.id === choiceId);
|
|
this.startDialogue([
|
|
{
|
|
speaker: view.definition.name,
|
|
textureKey: view.definition.textureKey,
|
|
text: choice?.response ?? '함께 나눈 결정은 이미 다음 출전을 위한 약속으로 남아 있습니다.'
|
|
},
|
|
{
|
|
speaker: '체류 기록',
|
|
text: choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료'
|
|
}
|
|
], undefined, view.definition.id);
|
|
return;
|
|
}
|
|
|
|
const lines = dialogue.lines.map((line) => this.authoredDialogueLine(line, view.definition));
|
|
this.startDialogue(lines, () => this.openChoicePanel(), view.definition.id);
|
|
}
|
|
|
|
private authoredDialogueLine(line: string, companion: CityStayExplorationActor): CityDialogueLine {
|
|
const separator = line.indexOf(':');
|
|
const speaker = separator > 0 ? line.slice(0, separator).trim() : companion.name;
|
|
const text = separator > 0 ? line.slice(separator + 1).trim() : line;
|
|
return {
|
|
speaker,
|
|
text,
|
|
textureKey: speaker === '유비' ? 'unit-liu-bei' : companion.textureKey
|
|
};
|
|
}
|
|
|
|
private openChoicePanel() {
|
|
if (this.choicePanel || this.navigationPending) {
|
|
return;
|
|
}
|
|
this.stopPlayerMovement();
|
|
this.promptBackground?.setVisible(false);
|
|
this.promptText?.setVisible(false);
|
|
const dialogue = this.cityStay.dialogue;
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.56)
|
|
.setOrigin(0)
|
|
.setInteractive();
|
|
const panel = this.add.rectangle(110, 610, 1700, 360, 0x151c26, 0.99)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(3, this.profile.accentColor, 0.94);
|
|
const title = this.add.text(150, 650, dialogue.title, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '31px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
});
|
|
const hint = this.add.text(150, 697, '유비의 답을 선택하세요. 선택은 저장되며 공명 경험치가 달라집니다.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#aeb8c4'
|
|
});
|
|
const objects: Phaser.GameObjects.GameObject[] = [shade, panel, title, hint];
|
|
this.choiceViews = dialogue.choices.map((choice, index) => {
|
|
const x = 150 + index * 820;
|
|
const y = 755;
|
|
const width = 790;
|
|
const button = this.add.rectangle(x, y, width, 160, 0x1d2936, 0.98)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, this.profile.accentColor, 0.76)
|
|
.setInteractive({ useHandCursor: true });
|
|
const number = this.add.text(x + 28, y + 24, `${index + 1}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '27px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
});
|
|
const label = this.add.text(x + 76, y + 24, choice.label, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '23px',
|
|
color: '#f0ede5',
|
|
fontStyle: 'bold',
|
|
wordWrap: { width: 670, useAdvancedWrap: true }
|
|
});
|
|
const reward = this.add.text(
|
|
x + 76,
|
|
y + 98,
|
|
`공명 +${dialogue.rewardExp + choice.rewardExp}`,
|
|
{
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#a8d58e',
|
|
fontStyle: 'bold'
|
|
}
|
|
);
|
|
button.on('pointerover', () => button.setFillStyle(0x2a3c4e, 0.99));
|
|
button.on('pointerout', () => button.setFillStyle(0x1d2936, 0.98));
|
|
button.on('pointerdown', () => this.chooseDialogue(choice));
|
|
objects.push(button, number, label, reward);
|
|
return { choiceId: choice.id, button, interactive: true };
|
|
});
|
|
const cancel = this.add.text(1740, 940, 'ESC · 나중에 결정', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#8f9aa7'
|
|
}).setOrigin(1, 0.5);
|
|
objects.push(cancel);
|
|
this.choicePanel = this.add.container(0, 0, objects).setDepth(3200);
|
|
}
|
|
|
|
private chooseDialogueByIndex(index: number) {
|
|
if (!this.choicePanel || this.navigationPending) {
|
|
return;
|
|
}
|
|
const choice = this.cityStay.dialogue.choices[index];
|
|
if (choice) {
|
|
this.chooseDialogue(choice);
|
|
}
|
|
}
|
|
|
|
private chooseDialogue(choice: CityResonanceDialogueChoice) {
|
|
const result = chooseCityStayResonance(this.cityStay.id, choice.id);
|
|
if (!result.ok) {
|
|
this.closeChoicePanel();
|
|
const message = result.reason === 'already-completed'
|
|
? '이미 완료한 공명 대화입니다.'
|
|
: result.reason === 'bond-unavailable'
|
|
? '두 동료의 공명 관계를 찾지 못했습니다.'
|
|
: '선택을 저장하지 못했습니다.';
|
|
this.showCompletionToast(message, false);
|
|
this.refreshProgressHud();
|
|
return;
|
|
}
|
|
this.campaign = result.campaign;
|
|
this.lastNotice = `공명 +${result.rewardExp} · ${result.choice.label}`;
|
|
this.closeChoicePanel();
|
|
soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 });
|
|
this.refreshProgressHud();
|
|
this.refreshActorMarkers();
|
|
this.startDialogue([
|
|
{
|
|
speaker: this.companionActor().name,
|
|
textureKey: this.companionActor().textureKey,
|
|
text: choice.response
|
|
},
|
|
{
|
|
speaker: '공명',
|
|
text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}`
|
|
}
|
|
], () => this.showCompletionToast('동료 공명 대화 완료'), this.companionActor().id);
|
|
}
|
|
|
|
private closeChoicePanel() {
|
|
this.choicePanel?.destroy();
|
|
this.choicePanel = undefined;
|
|
this.choiceViews = [];
|
|
this.refreshInteractionPrompt();
|
|
}
|
|
|
|
private openShop() {
|
|
if (this.shopPanel || this.navigationPending) {
|
|
return;
|
|
}
|
|
this.stopPlayerMovement();
|
|
this.shopNotice = '';
|
|
this.renderShopPanel();
|
|
}
|
|
|
|
private renderShopPanel() {
|
|
this.shopPanel?.destroy();
|
|
this.shopOfferViews = [];
|
|
const campaign = getCampaignState();
|
|
this.campaign = campaign;
|
|
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x05070a, 0.62)
|
|
.setOrigin(0)
|
|
.setInteractive();
|
|
const panel = this.add.rectangle(180, 126, 1560, 828, 0x151c26, 0.995)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(3, this.profile.accentColor, 0.94);
|
|
const title = this.add.text(230, 174, `${this.cityStay.city.name} 시장과 대장간`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '36px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
});
|
|
const gold = this.add.text(1685, 184, `군자금 ${campaign.gold}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '25px',
|
|
color: '#f3dfaa',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(1, 0);
|
|
const guide = this.add.text(230, 225, '일반 장비는 여러 개 구매할 수 있으며, 구입 즉시 군영 장비 탭에서 사용할 수 있습니다.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: '#aeb8c4'
|
|
});
|
|
const closeButton = this.add.rectangle(1625, 236, 120, 44, 0x263342, 0.98)
|
|
.setStrokeStyle(1, 0x738194, 0.8)
|
|
.setInteractive({ useHandCursor: true });
|
|
const closeLabel = this.add.text(1625, 236, '닫기 · ESC', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '17px',
|
|
color: '#dce2e8',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5);
|
|
closeButton.on('pointerdown', () => this.closeShop());
|
|
closeButton.on('pointerover', () => closeButton.setFillStyle(0x34485c, 0.99));
|
|
closeButton.on('pointerout', () => closeButton.setFillStyle(0x263342, 0.98));
|
|
|
|
const objects: Phaser.GameObjects.GameObject[] = [
|
|
shade,
|
|
panel,
|
|
title,
|
|
gold,
|
|
guide,
|
|
closeButton,
|
|
closeLabel
|
|
];
|
|
|
|
this.cityStay.equipmentOffers.forEach((offer, index) => {
|
|
const item = getItem(offer.itemId);
|
|
const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0;
|
|
const canBuy = campaign.gold >= offer.price;
|
|
const rowX = 230;
|
|
const rowY = 300 + index * 180;
|
|
const row = this.add.rectangle(rowX, rowY, 1460, 146, canBuy ? 0x1b2734 : 0x131a22, 0.98)
|
|
.setOrigin(0)
|
|
.setStrokeStyle(2, canBuy ? 0x54708a : 0x3f4853, canBuy ? 0.76 : 0.45);
|
|
const slotBadge = this.add.circle(rowX + 68, rowY + 73, 42, 0x0e151d, 0.98)
|
|
.setStrokeStyle(2, this.profile.accentColor, 0.74);
|
|
const slotLabel = this.add.text(rowX + 68, rowY + 73, equipmentSlotLabels[item.slot].slice(0, 2), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: '#f1d691',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5);
|
|
const name = this.add.text(rowX + 132, rowY + 24, `${item.name} · 보유 ${owned}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '25px',
|
|
color: canBuy ? '#f0ede5' : '#808a95',
|
|
fontStyle: 'bold'
|
|
});
|
|
const description = this.add.text(rowX + 132, rowY + 66, item.description, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '18px',
|
|
color: canBuy ? '#aeb8c4' : '#69737d',
|
|
wordWrap: { width: 850, useAdvancedWrap: true }
|
|
});
|
|
const bonus = this.add.text(rowX + 132, rowY + 104, this.itemBonusText(item), {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '16px',
|
|
color: canBuy ? '#9ebd98' : '#68736a'
|
|
});
|
|
const price = this.add.text(rowX + 1195, rowY + 43, `${offer.price}금`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: canBuy ? '#f1d691' : '#7f8994',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(1, 0);
|
|
const button = this.add.rectangle(rowX + 1350, rowY + 73, 170, 64, canBuy ? 0x4a371d : 0x20262d, 0.99)
|
|
.setStrokeStyle(2, canBuy ? this.profile.accentColor : 0x56606a, canBuy ? 0.9 : 0.45);
|
|
const buttonLabel = this.add.text(rowX + 1350, rowY + 73, canBuy ? '구매' : '군자금 부족', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: canBuy ? '21px' : '16px',
|
|
color: canBuy ? '#fff2b8' : '#7f8994',
|
|
fontStyle: 'bold'
|
|
}).setOrigin(0.5);
|
|
if (canBuy) {
|
|
button.setInteractive({ useHandCursor: true });
|
|
button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99));
|
|
button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99));
|
|
button.on('pointerdown', () => this.buyOffer(offer));
|
|
}
|
|
this.shopOfferViews.push({ offerId: offer.id, button, interactive: canBuy });
|
|
objects.push(
|
|
row,
|
|
slotBadge,
|
|
slotLabel,
|
|
name,
|
|
description,
|
|
bonus,
|
|
price,
|
|
button,
|
|
buttonLabel
|
|
);
|
|
});
|
|
|
|
const notice = this.add.text(230, 876, this.shopNotice || '상인에게 필요한 장비를 골라 보세요.', {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '19px',
|
|
color: this.shopNotice ? '#a8d58e' : '#8995a2',
|
|
fontStyle: this.shopNotice ? 'bold' : 'normal'
|
|
});
|
|
objects.push(notice);
|
|
this.shopPanel = this.add.container(0, 0, objects).setDepth(3100);
|
|
}
|
|
|
|
private buyOffer(offer: CityEquipmentOffer) {
|
|
const result = purchaseCityStayEquipment(this.cityStay.id, offer.id);
|
|
const message = result.ok
|
|
? `${result.item.name} 구입 · 군자금 -${result.offer.price}`
|
|
: result.reason === 'insufficient-gold'
|
|
? '군자금이 부족합니다.'
|
|
: result.reason === 'restricted-item'
|
|
? '보물 장비는 일반 시장에서 거래할 수 없습니다.'
|
|
: '현재 시장에서는 살 수 없는 장비입니다.';
|
|
this.shopNotice = message;
|
|
this.lastNotice = message;
|
|
if (result.ok) {
|
|
this.campaign = result.campaign;
|
|
soundDirector.playEffect('reward-reveal', { volume: 0.22, stopAfterMs: 720 });
|
|
} else {
|
|
soundDirector.playEffect('objective-failure', { volume: 0.16, stopAfterMs: 620 });
|
|
}
|
|
this.renderShopPanel();
|
|
this.refreshProgressHud();
|
|
}
|
|
|
|
private closeShop() {
|
|
this.shopPanel?.destroy();
|
|
this.shopPanel = undefined;
|
|
this.shopOfferViews = [];
|
|
this.shopNotice = '';
|
|
this.refreshInteractionPrompt();
|
|
}
|
|
|
|
private itemBonusText(item: ReturnType<typeof getItem>) {
|
|
const bonuses = [
|
|
item.attackBonus ? `공격 +${item.attackBonus}` : '',
|
|
item.defenseBonus ? `방어 +${item.defenseBonus}` : '',
|
|
item.strategyBonus ? `책략 +${item.strategyBonus}` : ''
|
|
].filter(Boolean);
|
|
return bonuses.length > 0 ? bonuses.join(' · ') : item.effects[0] ?? '일반 장비';
|
|
}
|
|
|
|
private interactWithExit() {
|
|
const campaign = getCampaignState();
|
|
const pending = [
|
|
!this.informationComplete(campaign) ? '정보 수집' : '',
|
|
!this.dialogueComplete(campaign) ? '동료 대화' : ''
|
|
].filter(Boolean);
|
|
if (pending.length > 0) {
|
|
this.startDialogue([
|
|
{
|
|
speaker: '유비',
|
|
textureKey: 'unit-liu-bei',
|
|
text: `${pending.join('과 ')}이 아직 남아 있다. 체류 활동은 선택이니 필요하면 다음에 다시 들르자.`
|
|
},
|
|
{
|
|
speaker: '길잡이',
|
|
text: '군영으로 돌아갑니다.'
|
|
}
|
|
], () => this.returnToCamp(), 'camp-exit');
|
|
return;
|
|
}
|
|
this.returnToCamp();
|
|
}
|
|
|
|
private returnToCamp() {
|
|
if (this.navigationPending) {
|
|
return;
|
|
}
|
|
this.navigationPending = true;
|
|
this.stopPlayerMovement();
|
|
const previousActiveCityStayId = getCampaignState().activeCityStayId;
|
|
this.campaign = setActiveCityStayId();
|
|
this.showTransitionOverlay();
|
|
|
|
void startGameScene(this, 'CampScene').catch((error) => {
|
|
console.error('Failed to return from the city stay.', error);
|
|
this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id);
|
|
this.navigationPending = false;
|
|
this.transitionOverlay?.destroy();
|
|
this.transitionOverlay = undefined;
|
|
this.showCompletionToast('군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', false);
|
|
this.refreshInteractionPrompt();
|
|
});
|
|
}
|
|
|
|
private showTransitionOverlay() {
|
|
const shade = this.add.rectangle(0, 0, sceneWidth, sceneHeight, 0x080b0f, 0.8).setOrigin(0);
|
|
const title = this.add.text(sceneWidth / 2, sceneHeight / 2 - 32, `${this.cityStay.city.name} 체류 기록`, {
|
|
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 startDialogue(
|
|
lines: CityDialogueLine[],
|
|
onComplete?: () => void,
|
|
sourceTargetId?: string
|
|
) {
|
|
if (!lines.length || this.navigationPending || this.shopPanel || this.choicePanel) {
|
|
return;
|
|
}
|
|
this.stopPlayerMovement();
|
|
this.dialogueState = { lines, lineIndex: 0, onComplete, sourceTargetId };
|
|
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.dialogueAvatar) {
|
|
const textureKey = line.textureKey;
|
|
if (textureKey && this.textures.exists(textureKey)) {
|
|
this.dialogueAvatar.setTexture(textureKey, 0).setVisible(true);
|
|
this.dialogueAvatar.play(`${textureKey}-idle-south`);
|
|
} else {
|
|
this.dialogueAvatar.setVisible(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 cancelDialogue() {
|
|
this.dialogueState = undefined;
|
|
this.dialoguePanel?.setVisible(false);
|
|
this.stopPlayerMovement();
|
|
this.refreshInteractionPrompt();
|
|
}
|
|
|
|
private refreshProgressHud() {
|
|
const campaign = getCampaignState();
|
|
this.campaign = campaign;
|
|
const informationComplete = this.informationComplete(campaign);
|
|
const dialogueComplete = this.dialogueComplete(campaign);
|
|
const completed = Number(informationComplete) + Number(dialogueComplete);
|
|
|
|
this.informationStatus?.setText(informationComplete ? '✓ 정보 확보 완료' : '◆ 진행 가능');
|
|
this.informationStatus?.setColor(informationComplete ? '#a8d58e' : '#e1bc69');
|
|
this.informationCard?.setStrokeStyle(
|
|
2,
|
|
informationComplete ? 0x628653 : this.profile.accentColor,
|
|
informationComplete ? 0.9 : 0.76
|
|
);
|
|
this.dialogueStatus?.setText(dialogueComplete ? '✓ 공명 대화 완료' : '◆ 진행 가능');
|
|
this.dialogueStatus?.setColor(dialogueComplete ? '#a8d58e' : '#e1bc69');
|
|
this.dialogueCard?.setStrokeStyle(
|
|
2,
|
|
dialogueComplete ? 0x628653 : this.profile.accentColor,
|
|
dialogueComplete ? 0.9 : 0.76
|
|
);
|
|
this.progressText?.setText(
|
|
completed < 2
|
|
? `체류 기록 ${completed} / 2\n미완료 활동을 남겨 두고도 군영으로 돌아갈 수 있습니다.`
|
|
: `주요 체류 활동 완료\n시장 이용 후 군영 출구로 돌아가세요.`
|
|
);
|
|
}
|
|
|
|
private refreshActorMarkers() {
|
|
const campaign = getCampaignState();
|
|
this.actors.forEach(({ definition, marker }) => {
|
|
const completed = definition.kind === 'information'
|
|
? this.informationComplete(campaign)
|
|
: definition.kind === 'dialogue'
|
|
? this.dialogueComplete(campaign)
|
|
: false;
|
|
marker.setText(completed ? '✓' : this.markerText(definition.kind));
|
|
marker.setColor(completed ? '#e5f2d5' : '#2a2013');
|
|
marker.setBackgroundColor(completed ? '#4e7549' : '#e5bd68');
|
|
});
|
|
}
|
|
|
|
private markerText(kind: CityStayExplorationTargetKind) {
|
|
return kind === 'market' ? '전' : kind === 'exit' ? '↩' : '!';
|
|
}
|
|
|
|
private refreshInteractionPrompt() {
|
|
if (!this.ready || this.modalOpen() || 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 showPromptMessage(message: string) {
|
|
this.lastNotice = message;
|
|
this.promptBackground?.setVisible(true);
|
|
this.promptText?.setText(message).setVisible(true);
|
|
this.time.delayedCall(850, () => this.refreshInteractionPrompt());
|
|
}
|
|
|
|
private showCompletionToast(label: string, success = true) {
|
|
this.completionToast?.destroy();
|
|
this.lastNotice = label;
|
|
const background = this.add.rectangle(748, 132, 520, 72, success ? 0x17251c : 0x38201f, 0.98)
|
|
.setStrokeStyle(2, success ? 0x8fbd72 : 0xb36f62, 0.92);
|
|
const text = this.add.text(748, 132, `${success ? '✓' : '!'} ${label}`, {
|
|
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
|
|
fontSize: '22px',
|
|
color: success ? '#dff0c9' : '#f2cbc2',
|
|
fontStyle: 'bold',
|
|
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();
|
|
this.completionToast = undefined;
|
|
}
|
|
});
|
|
}
|
|
|
|
private nearestInteractionTarget(radius: number) {
|
|
if (!this.player) {
|
|
return undefined;
|
|
}
|
|
return this.allInteractionTargets()
|
|
.map((target) => ({ target, distance: this.distanceTo(target.x, target.y) }))
|
|
.filter(({ distance }) => distance <= radius)
|
|
.sort((left, right) => left.distance - right.distance)[0]?.target;
|
|
}
|
|
|
|
private allInteractionTargets(): CityInteractionTarget[] {
|
|
return [
|
|
...Array.from(this.actors.values()).map(({ definition, sprite }) => ({
|
|
kind: definition.kind,
|
|
id: definition.id,
|
|
name: definition.kind === 'market'
|
|
? `${definition.name}에게 장비 보기`
|
|
: `${definition.name}와 대화`,
|
|
x: sprite.x,
|
|
y: sprite.y
|
|
})),
|
|
{
|
|
kind: 'exit',
|
|
id: 'camp-exit',
|
|
name: this.profile.exit.name,
|
|
x: this.profile.exit.x,
|
|
y: this.profile.exit.y
|
|
}
|
|
];
|
|
}
|
|
|
|
private interactionTargetByIdOrKind(targetIdOrKind: string) {
|
|
return this.allInteractionTargets().find((target) => (
|
|
target.id === targetIdOrKind || target.kind === targetIdOrKind
|
|
));
|
|
}
|
|
|
|
private faceCharactersForConversation(view: CityActorView) {
|
|
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.definition.textureKey)) {
|
|
view.sprite.play(`${view.definition.textureKey}-idle-${npcDirection}`);
|
|
}
|
|
}
|
|
|
|
private informationComplete(campaign = getCampaignState()) {
|
|
return campaign.completedCampVisits.includes(this.cityStay.information.id);
|
|
}
|
|
|
|
private dialogueComplete(campaign = getCampaignState()) {
|
|
return campaign.completedCampDialogues.includes(this.cityStay.dialogue.id);
|
|
}
|
|
|
|
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
|
|
))
|
|
.join(' · ');
|
|
}
|
|
|
|
private companionActor() {
|
|
return this.profile.actors.find((actor) => actor.kind === 'dialogue')!;
|
|
}
|
|
|
|
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 modalOpen() {
|
|
return Boolean(this.dialogueState || this.shopPanel || this.choicePanel);
|
|
}
|
|
|
|
private boundsSnapshot(bounds: Phaser.Geom.Rectangle) {
|
|
return {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
};
|
|
}
|
|
}
|