Improve battle unit feedback and detail panel

This commit is contained in:
2026-06-29 01:47:53 +09:00
parent e4f2754b08
commit d8177c88dc
2 changed files with 301 additions and 29 deletions

View File

@@ -17,6 +17,8 @@ class SoundDirector {
private currentMusicKey?: string; private currentMusicKey?: string;
private pendingMusicKey?: string; private pendingMusicKey?: string;
private musicFadeTimer?: number; private musicFadeTimer?: number;
private lastEffect?: { key: string; at: number; volume: number; rate: number };
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
private readonly musicVolume = 0.22; private readonly musicVolume = 0.22;
private readonly effectVolume = 0.42; private readonly effectVolume = 0.42;
@@ -118,6 +120,14 @@ class SoundDirector {
this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 }); this.playEffect('footstep-walk', { volume: 0.2, rate: 1.35, stopAfterMs: 320 });
} }
playMovementStep(mounted: boolean, stepIndex: number, options: PlayEffectOptions = {}) {
const key = mounted ? 'horse-gallop' : 'footstep-walk';
this.lastMovementStep = { key, mounted, stepIndex, at: performance.now(), volume: options.volume, rate: options.rate };
const played = this.playEffect(key, options);
this.playStepPulse(mounted, stepIndex, options.volume);
return played;
}
playMeleeSwing(hit: boolean, critical: boolean) { playMeleeSwing(hit: boolean, critical: boolean) {
this.playEffect('sword-slash', { this.playEffect('sword-slash', {
volume: critical ? 0.62 : hit ? 0.52 : 0.38, volume: critical ? 0.62 : hit ? 0.52 : 0.38,
@@ -169,6 +179,7 @@ class SoundDirector {
effect.volume = options.volume ?? this.effectVolume; effect.volume = options.volume ?? this.effectVolume;
effect.playbackRate = options.rate ?? 1; effect.playbackRate = options.rate ?? 1;
effect.muted = this.muted; effect.muted = this.muted;
this.lastEffect = { key, at: performance.now(), volume: effect.volume, rate: effect.playbackRate };
effect.addEventListener( effect.addEventListener(
'ended', 'ended',
() => { () => {
@@ -188,6 +199,18 @@ class SoundDirector {
return true; return true;
} }
getDebugState() {
return {
started: this.started,
muted: this.muted,
contextState: this.context?.state ?? null,
currentMusicKey: this.currentMusicKey ?? null,
pendingMusicKey: this.pendingMusicKey ?? null,
lastEffect: this.lastEffect ?? null,
lastMovementStep: this.lastMovementStep ?? null
};
}
playMusic(key?: string) { playMusic(key?: string) {
if (!key) { if (!key) {
return; return;
@@ -248,6 +271,51 @@ class SoundDirector {
oscillator.stop(now + duration + 0.04); oscillator.stop(now + duration + 0.04);
} }
private playStepPulse(mounted: boolean, stepIndex: number, volume = 0.2) {
if (!this.context || this.muted) {
return;
}
const scale = Math.max(0.35, Math.min(1.2, volume / 0.24));
const start = this.context.currentTime;
const primaryFrequency = mounted ? 112 + (stepIndex % 2) * 26 : 148 + (stepIndex % 2) * 34;
const duration = mounted ? 0.09 : 0.075;
const gainValue = (mounted ? 0.022 : 0.016) * scale;
this.playPercussivePulse(primaryFrequency, start, duration, gainValue, mounted ? 'square' : 'triangle');
if (mounted) {
this.playPercussivePulse(82 + (stepIndex % 2) * 18, start + 0.055, 0.075, gainValue * 0.78, 'square');
}
}
private playPercussivePulse(
frequency: number,
start: number,
duration: number,
gainValue: number,
type: OscillatorType
) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(40, frequency * 0.62), start + duration);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(gainValue, start + 0.006);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(this.master ?? this.context.destination);
oscillator.start(start);
oscillator.stop(start + duration + 0.025);
}
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) { private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
if (this.musicFadeTimer) { if (this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer); window.clearInterval(this.musicFadeTimer);

View File

@@ -2880,6 +2880,8 @@ export class BattleScene extends Phaser.Scene {
private turnPromptObjects: Phaser.GameObjects.GameObject[] = []; private turnPromptObjects: Phaser.GameObjects.GameObject[] = [];
private turnPromptMode?: TurnPromptMode; private turnPromptMode?: TurnPromptMode;
private combatCutInObjects: Phaser.GameObjects.GameObject[] = []; private combatCutInObjects: Phaser.GameObjects.GameObject[] = [];
private facingIndicatorObjects: Phaser.GameObjects.GameObject[] = [];
private facingIndicatorUnitId?: string;
private resultObjects: Phaser.GameObjects.GameObject[] = []; private resultObjects: Phaser.GameObjects.GameObject[] = [];
private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultGaugeAnimations: ResultGaugeAnimation[] = [];
private resultAnimationToken = 0; private resultAnimationToken = 0;
@@ -2905,6 +2907,7 @@ export class BattleScene extends Phaser.Scene {
private miniMapObjects: Phaser.GameObjects.GameObject[] = []; private miniMapObjects: Phaser.GameObjects.GameObject[] = [];
private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>(); private miniMapUnitDots = new Map<string, Phaser.GameObjects.Rectangle>();
private miniMapViewport?: Phaser.GameObjects.Rectangle; private miniMapViewport?: Phaser.GameObjects.Rectangle;
private miniMapVisible = true;
private cameraTileX = 0; private cameraTileX = 0;
private cameraTileY = 0; private cameraTileY = 0;
private edgeScrollElapsed = 0; private edgeScrollElapsed = 0;
@@ -2952,6 +2955,7 @@ export class BattleScene extends Phaser.Scene {
this.phase = 'idle'; this.phase = 'idle';
soundDirector.playMusic('battle-prep'); soundDirector.playMusic('battle-prep');
this.input.mouse?.disableContextMenu(); this.input.mouse?.disableContextMenu();
this.installBattleAudioUnlock();
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (pointer.rightButtonDown()) { if (pointer.rightButtonDown()) {
this.handleRightClick(pointer); this.handleRightClick(pointer);
@@ -2968,6 +2972,16 @@ export class BattleScene extends Phaser.Scene {
this.ensureScenarioAssets(() => this.drawBattleScene()); this.ensureScenarioAssets(() => this.drawBattleScene());
} }
private installBattleAudioUnlock() {
const unlock = () => {
soundDirector.start();
soundDirector.resume();
};
this.input.once('pointerdown', unlock);
this.input.keyboard?.once('keydown', unlock);
}
private drawBattleScene() { private drawBattleScene() {
this.drawMap(); this.drawMap();
this.drawUnits(); this.drawUnits();
@@ -3415,12 +3429,19 @@ export class BattleScene extends Phaser.Scene {
battleUnits.forEach((unit) => this.positionUnitView(unit)); battleUnits.forEach((unit) => this.positionUnitView(unit));
this.updateMiniMap(); this.updateMiniMap();
this.refreshFacingIndicator();
} }
private updateMiniMap() { private updateMiniMap() {
if (!this.miniMapLayout) { if (!this.miniMapLayout) {
return; return;
} }
if (!this.miniMapVisible) {
this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, false));
return;
}
this.miniMapObjects.forEach((object) => this.setMiniMapObjectVisible(object, true));
const { x, y, cellSize } = this.miniMapLayout; const { x, y, cellSize } = this.miniMapLayout;
this.miniMapUnitDots.forEach((dot, unitId) => { this.miniMapUnitDots.forEach((dot, unitId) => {
@@ -3436,6 +3457,22 @@ export class BattleScene extends Phaser.Scene {
this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize); this.miniMapViewport?.setPosition(x + this.cameraTileX * cellSize, y + this.cameraTileY * cellSize);
} }
private setMiniMapVisible(visible: boolean) {
this.miniMapVisible = visible;
this.updateMiniMap();
}
private setMiniMapObjectVisible(object: Phaser.GameObjects.GameObject, visible: boolean) {
const visibleObject = object as Phaser.GameObjects.GameObject & {
setVisible: (value: boolean) => Phaser.GameObjects.GameObject;
input?: { enabled: boolean };
};
visibleObject.setVisible(visible);
if (visibleObject.input) {
visibleObject.input.enabled = visible;
}
}
private positionTileObject(object: Phaser.GameObjects.Rectangle, x: number, y: number) { private positionTileObject(object: Phaser.GameObjects.Rectangle, x: number, y: number) {
const screenX = this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize; const screenX = this.layout.gridX + (x - this.cameraTileX) * this.layout.tileSize;
const screenY = this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize; const screenY = this.layout.gridY + (y - this.cameraTileY) * this.layout.tileSize;
@@ -7138,13 +7175,14 @@ export class BattleScene extends Phaser.Scene {
) )
); );
attackerSprite.setDepth(depth + 6); attackerSprite.setDepth(depth + 6);
attackerSprite.setDisplaySize(126, 126); this.applyCombatSpriteDisplaySize(attackerSprite, result.attacker);
this.applyActionSpriteBlend(attackerSprite);
const defenderSprite = this.trackCombatObject( const defenderSprite = this.trackCombatObject(
this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection)) this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection))
); );
defenderSprite.setDepth(depth + 6); defenderSprite.setDepth(depth + 6);
defenderSprite.setDisplaySize(126, 126); this.applyCombatSpriteDisplaySize(defenderSprite, result.defender);
const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth); const attackerStatus = this.renderCombatStatusPanel(result.attacker, left + 26, top + 286, 392, result.characterGrowth);
const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392); const defenderStatus = this.renderCombatStatusPanel(result.defender, left + panelWidth - 418, top + 286, 392);
@@ -7211,6 +7249,28 @@ export class BattleScene extends Phaser.Scene {
this.hideCombatCutIn(); this.hideCombatCutIn();
} }
private applyCombatSpriteDisplaySize(sprite: Phaser.GameObjects.Sprite, unit: UnitData, compact = false) {
const size = this.combatSpriteDisplaySize(unit, compact);
sprite.setDisplaySize(size, size);
}
private combatSpriteDisplaySize(unit: UnitData, compact = false) {
const baseSizeByClass: Record<UnitClassKey, number> = {
lord: 128,
infantry: 124,
cavalry: 136,
spearman: 126,
archer: 120,
strategist: 116,
quartermaster: 116,
bandit: 124,
yellowTurban: 124,
rebelLeader: 128
};
const size = baseSizeByClass[unit.classKey] ?? 124;
return Math.round(size * (compact ? 0.9 : 1));
}
private async playSupportCutIn(result: SupportResult) { private async playSupportCutIn(result: SupportResult) {
this.hideCombatCutIn(); this.hideCombatCutIn();
@@ -7245,12 +7305,13 @@ export class BattleScene extends Phaser.Scene {
this.add.sprite(left + 160, top + 146, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose)) this.add.sprite(left + 160, top + 146, this.unitActionTexture(result.user), this.unitActionFrameIndex('east', supportPose))
); );
userSprite.setDepth(depth + 3); userSprite.setDepth(depth + 3);
userSprite.setDisplaySize(112, 112); this.applyCombatSpriteDisplaySize(userSprite, result.user, true);
this.applyActionSpriteBlend(userSprite);
void this.playUnitActionFrames(userSprite, result.user, 'east', supportPose, 100, 2); void this.playUnitActionFrames(userSprite, result.user, 'east', supportPose, 100, 2);
const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, this.unitTextureKey(result.target), this.unitFrameIndex('west'))); const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, this.unitTextureKey(result.target), this.unitFrameIndex('west')));
targetSprite.setDepth(depth + 3); targetSprite.setDepth(depth + 3);
targetSprite.setDisplaySize(112, 112); this.applyCombatSpriteDisplaySize(targetSprite, result.target, true);
const effectColor = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f; const effectColor = result.usable.effect === 'heal' ? 0x59d18c : 0xd8b15f;
const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 145, 30, effectColor, 0.76)); const effect = this.trackCombatObject(this.add.circle(left + panelWidth / 2, top + 145, 30, effectColor, 0.76));
@@ -7776,6 +7837,7 @@ export class BattleScene extends Phaser.Scene {
this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit))) this.add.sprite(x, y, this.unitActionTexture(unit), this.unitActionFrameIndex('south', this.celebrationPose(unit)))
); );
sprite.setDisplaySize(82, 82); sprite.setDisplaySize(82, 82);
this.applyActionSpriteBlend(sprite);
sprite.setDepth(depth); sprite.setDepth(depth);
return sprite; return sprite;
} }
@@ -7855,6 +7917,8 @@ export class BattleScene extends Phaser.Scene {
const mounted = result.attacker.classKey === 'cavalry'; const mounted = result.attacker.classKey === 'cavalry';
const windupX = attackerX - direction * 28; const windupX = attackerX - direction * 28;
const contactX = defenderX - direction * 126; const contactX = defenderX - direction * 126;
const baseScaleX = attackerSprite.scaleX;
const baseScaleY = attackerSprite.scaleY;
soundDirector.playMeleeRush(mounted); soundDirector.playMeleeRush(mounted);
void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', mounted ? 72 : 82); void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', 'attack', mounted ? 72 : 82);
@@ -7863,7 +7927,8 @@ export class BattleScene extends Phaser.Scene {
targets: attackerSprite, targets: attackerSprite,
x: windupX, x: windupX,
y: groundY + 5, y: groundY + 5,
scaleX: 0.96, scaleX: baseScaleX * 0.96,
scaleY: baseScaleY,
duration: 110, duration: 110,
ease: 'Sine.easeIn' ease: 'Sine.easeIn'
}); });
@@ -7875,7 +7940,8 @@ export class BattleScene extends Phaser.Scene {
targets: attackerSprite, targets: attackerSprite,
x: contactX, x: contactX,
y: groundY - (mounted ? 4 : 2), y: groundY - (mounted ? 4 : 2),
scaleX: 1.05, scaleX: baseScaleX * 1.05,
scaleY: baseScaleY,
duration: mounted ? 170 : 205, duration: mounted ? 170 : 205,
ease: 'Cubic.easeIn' ease: 'Cubic.easeIn'
}); });
@@ -7906,7 +7972,8 @@ export class BattleScene extends Phaser.Scene {
targets: attackerSprite, targets: attackerSprite,
x: attackerX, x: attackerX,
y: groundY, y: groundY,
scaleX: 1, scaleX: baseScaleX,
scaleY: baseScaleY,
duration: 180, duration: 180,
ease: 'Sine.easeOut' ease: 'Sine.easeOut'
}); });
@@ -7991,10 +8058,13 @@ export class BattleScene extends Phaser.Scene {
} }
void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', this.actionPoseForCommand(result.action), 92); void this.playUnitActionFrames(attackerSprite, result.attacker, 'east', this.actionPoseForCommand(result.action), 92);
const baseScaleX = attackerSprite.scaleX;
const baseScaleY = attackerSprite.scaleY;
this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, result.action === 'item' ? 0xd8b15f : 0x6cc5ff); this.createCasterPulse(attackerX + direction * 48, groundY - 50, depth + 2, result.action === 'item' ? 0xd8b15f : 0x6cc5ff);
this.tweens.add({ this.tweens.add({
targets: attackerSprite, targets: attackerSprite,
scale: 1.08, scaleX: baseScaleX * 1.08,
scaleY: baseScaleY * 1.08,
y: groundY - 4, y: groundY - 4,
duration: 150, duration: 150,
yoyo: true, yoyo: true,
@@ -8486,6 +8556,7 @@ export class BattleScene extends Phaser.Scene {
this.setUnitBasePosition(view, targetX, targetY); this.setUnitBasePosition(view, targetX, targetY);
this.syncUnitMotion(unit, view, direction); this.syncUnitMotion(unit, view, direction);
this.updateMiniMap(); this.updateMiniMap();
this.refreshFacingIndicator(unit.id);
resolve(); resolve();
} }
}); });
@@ -9019,6 +9090,7 @@ export class BattleScene extends Phaser.Scene {
this.setUnitBasePosition(view, targetX, targetY); this.setUnitBasePosition(view, targetX, targetY);
this.syncUnitMotion(unit, view, direction); this.syncUnitMotion(unit, view, direction);
this.updateMiniMap(); this.updateMiniMap();
this.refreshFacingIndicator(unit.id);
} }
}); });
this.tweens.add({ this.tweens.add({
@@ -9047,7 +9119,6 @@ export class BattleScene extends Phaser.Scene {
private playMovementSound(unit: UnitData, duration: number, tileDistance = 1) { private playMovementSound(unit: UnitData, duration: number, tileDistance = 1) {
const isMounted = this.isMountedUnit(unit); const isMounted = this.isMountedUnit(unit);
const key = isMounted ? 'horse-gallop' : 'footstep-walk';
const pulseCount = isMounted const pulseCount = isMounted
? Math.max(3, Math.min(10, tileDistance * 2 + 1)) ? Math.max(3, Math.min(10, tileDistance * 2 + 1))
: Math.max(2, Math.min(8, tileDistance + 1)); : Math.max(2, Math.min(8, tileDistance + 1));
@@ -9055,7 +9126,7 @@ export class BattleScene extends Phaser.Scene {
for (let index = 0; index < pulseCount; index += 1) { for (let index = 0; index < pulseCount; index += 1) {
this.time.delayedCall(Math.round(index * interval), () => { this.time.delayedCall(Math.round(index * interval), () => {
soundDirector.playEffect(key, { soundDirector.playMovementStep(isMounted, index, {
volume: isMounted ? 0.24 : 0.18, volume: isMounted ? 0.24 : 0.18,
rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12, rate: isMounted ? 0.98 + (index % 2) * 0.12 : 0.9 + (index % 2) * 0.12,
stopAfterMs: Math.round(Math.min(isMounted ? 210 : 260, interval + 80)) stopAfterMs: Math.round(Math.min(isMounted ? 210 : 260, interval + 80))
@@ -9071,6 +9142,107 @@ export class BattleScene extends Phaser.Scene {
return deltaY >= 0 ? 'south' : 'north'; return deltaY >= 0 ? 'south' : 'north';
} }
private currentUnitDirection(unit: UnitData): UnitDirection {
return this.unitViews.get(unit.id)?.direction ?? 'south';
}
private unitDirectionLabel(direction: UnitDirection) {
const labels: Record<UnitDirection, string> = {
south: '아래',
east: '오른쪽',
north: '위',
west: '왼쪽'
};
return labels[direction];
}
private directionSymbol(direction: UnitDirection) {
const symbols: Record<UnitDirection, string> = {
south: '▼',
east: '▶',
north: '▲',
west: '◀'
};
return symbols[direction];
}
private directionVector(direction: UnitDirection) {
const vectors: Record<UnitDirection, { x: number; y: number }> = {
south: { x: 0, y: 1 },
east: { x: 1, y: 0 },
north: { x: 0, y: -1 },
west: { x: -1, y: 0 }
};
return vectors[direction];
}
private refreshFacingIndicator(unitId = this.facingIndicatorUnitId) {
if (!unitId) {
return;
}
const unit = battleUnits.find((candidate) => candidate.id === unitId);
if (!unit) {
this.clearFacingIndicator();
return;
}
this.showFacingIndicator(unit);
}
private showFacingIndicator(unit: UnitData) {
this.clearFacingIndicator();
this.facingIndicatorUnitId = unit.id;
const view = this.unitViews.get(unit.id);
if (!view || unit.hp <= 0 || !this.isTileVisible(unit.x, unit.y)) {
return;
}
const direction = this.currentUnitDirection(unit);
const vector = this.directionVector(direction);
const x = view.sprite.x + vector.x * this.layout.tileSize * 0.34;
const y = view.sprite.y + vector.y * this.layout.tileSize * 0.34 - this.layout.tileSize * 0.28;
const marker = this.add.circle(x, y, 13, 0x101820, 0.86);
marker.setStrokeStyle(1, palette.gold, 0.88);
marker.setDepth(34);
const arrow = this.add.text(x, y - 1, this.directionSymbol(direction), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px',
color: '#ffe28a',
fontStyle: '700',
stroke: '#05070a',
strokeThickness: 3
});
arrow.setOrigin(0.5);
arrow.setDepth(35);
const label = this.add.text(x, y + 16, this.unitDirectionLabel(direction), {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '10px',
color: '#f4dfad',
stroke: '#05070a',
strokeThickness: 3
});
label.setOrigin(0.5, 0);
label.setDepth(35);
if (this.mapMask) {
marker.setMask(this.mapMask);
arrow.setMask(this.mapMask);
label.setMask(this.mapMask);
}
this.facingIndicatorObjects = [marker, arrow, label];
}
private clearFacingIndicator() {
this.facingIndicatorObjects.forEach((object) => object.destroy());
this.facingIndicatorObjects = [];
this.facingIndicatorUnitId = undefined;
}
private playUnitWalk(view: UnitView, direction: UnitDirection) { private playUnitWalk(view: UnitView, direction: UnitDirection) {
view.direction = direction; view.direction = direction;
this.stopUnitIdle(view); this.stopUnitIdle(view);
@@ -9110,6 +9282,7 @@ export class BattleScene extends Phaser.Scene {
private setUnitDirectionFrame(view: UnitView, direction: UnitDirection) { private setUnitDirectionFrame(view: UnitView, direction: UnitDirection) {
view.sprite.setTexture(view.textureBase, this.unitFrameIndex(direction)); view.sprite.setTexture(view.textureBase, this.unitFrameIndex(direction));
view.sprite.setFlipX(false); view.sprite.setFlipX(false);
this.applyBaseSpriteBlend(view.sprite);
} }
private setUnitBasePosition(view: UnitView, x: number, y: number) { private setUnitBasePosition(view: UnitView, x: number, y: number) {
@@ -9327,6 +9500,15 @@ export class BattleScene extends Phaser.Scene {
private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) { private setUnitActionFrame(sprite: Phaser.GameObjects.Sprite, unit: UnitData, direction: UnitDirection, pose: UnitActionPose, frame = 0) {
sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrameIndex(direction, pose, frame)); sprite.setTexture(this.unitActionTexture(unit), this.unitActionFrameIndex(direction, pose, frame));
this.applyActionSpriteBlend(sprite);
}
private applyActionSpriteBlend(sprite: Phaser.GameObjects.Sprite) {
sprite.setBlendMode(Phaser.BlendModes.SCREEN);
}
private applyBaseSpriteBlend(sprite: Phaser.GameObjects.Sprite) {
sprite.setBlendMode(Phaser.BlendModes.NORMAL);
} }
private playUnitActionFrames( private playUnitActionFrames(
@@ -9663,6 +9845,7 @@ export class BattleScene extends Phaser.Scene {
private renderSituationPanel(message?: string) { private renderSituationPanel(message?: string) {
this.clearSidePanelContent(); this.clearSidePanelContent();
this.setMiniMapVisible(true);
const { panelX, panelWidth } = this.layout; const { panelX, panelWidth } = this.layout;
const left = panelX + 24; const left = panelX + 24;
const width = panelWidth - 48; const width = panelWidth - 48;
@@ -9943,6 +10126,7 @@ export class BattleScene extends Phaser.Scene {
private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) {
this.rosterTab = tab; this.rosterTab = tab;
this.clearSidePanelContent(); this.clearSidePanelContent();
this.setMiniMapVisible(true);
const { panelX, panelY, panelWidth, panelHeight } = this.layout; const { panelX, panelY, panelWidth, panelHeight } = this.layout;
const left = panelX + 24; const left = panelX + 24;
@@ -10028,6 +10212,7 @@ export class BattleScene extends Phaser.Scene {
private renderUnitDetail(unit: UnitData, message?: string) { private renderUnitDetail(unit: UnitData, message?: string) {
this.clearSidePanelContent(); this.clearSidePanelContent();
this.setMiniMapVisible(false);
const { panelX, panelWidth } = this.layout; const { panelX, panelWidth } = this.layout;
const left = panelX + 24; const left = panelX + 24;
@@ -10039,6 +10224,7 @@ export class BattleScene extends Phaser.Scene {
const terrain = battleMap.terrain[unit.y][unit.x]; const terrain = battleMap.terrain[unit.y][unit.x];
const terrainRule = getTerrainRule(terrain); const terrainRule = getTerrainRule(terrain);
const terrainRating = unitClass.terrainRatings[terrain]; const terrainRating = unitClass.terrainRatings[terrain];
const direction = this.currentUnitDirection(unit);
const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94)); const header = this.trackSideObject(this.add.rectangle(left, top, width, 60, 0x101820, 0.94));
header.setOrigin(0); header.setOrigin(0);
@@ -10056,6 +10242,13 @@ export class BattleScene extends Phaser.Scene {
color: acted ? '#bdbdbd' : '#f2e3bf', color: acted ? '#bdbdbd' : '#f2e3bf',
fontStyle: '700' fontStyle: '700'
})); }));
const growthText = this.trackSideObject(this.add.text(left + width - 14, top + 42, `Lv ${unit.level} EXP ${unit.exp}/100`, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '13px',
color: acted ? '#a8a8a8' : '#f4dfad',
fontStyle: '700'
}));
growthText.setOrigin(1, 0);
if (this.phase !== 'command') { if (this.phase !== 'command') {
const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9)); const backBg = this.trackSideObject(this.add.rectangle(left + width - 66, top + 10, 52, 28, 0x1b2834, 0.9));
@@ -10089,31 +10282,40 @@ export class BattleScene extends Phaser.Scene {
this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c); this.drawGauge(left + 76, top + 84, width - 160, 10, unit.hp / unit.maxHp, 0x59d18c);
const attackBonus = this.equipmentAttackBonus(unit); const attackBonus = this.equipmentAttackBonus(unit);
this.renderSmallValueBox(left, top + 112, '공격', `${unit.attack + attackBonus}`); const summaryGap = 6;
this.renderSmallValueBox(left + width / 2 + 6, top + 112, '이동', `${unit.move}`); const summaryBoxWidth = (width - summaryGap * 2) / 3;
this.renderCompactValueBox(left, top + 112, summaryBoxWidth, '공격', `${unit.attack + attackBonus}`);
this.renderCompactValueBox(left + summaryBoxWidth + summaryGap, top + 112, summaryBoxWidth, '방어+', `${this.equipmentDefenseBonus(unit)}`);
this.renderCompactValueBox(
left + (summaryBoxWidth + summaryGap) * 2,
top + 112,
summaryBoxWidth,
'방향',
`${this.directionSymbol(direction)} ${this.unitDirectionLabel(direction)}`
);
const statTop = top + 158; const statTop = top + 158;
statLabels.forEach((stat, index) => { statLabels.forEach((stat, index) => {
this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width); this.renderStatRow(stat.label, unit.stats[stat.key], left, statTop + index * 28, width);
}); });
if (message) { const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}% / 이동 ${unit.move}${acted ? ' / 행동완료' : ''}`;
this.renderPanelMessage(message, left, top + 306, width, 66);
return;
}
const positionText = `위치 ${unit.x + 1}, ${unit.y + 1} / ${terrainRule.label} ${terrainRating}%${acted ? ' / 행동완료' : ''}`;
this.trackSideObject(this.add.text(left, top + 306, positionText, { this.trackSideObject(this.add.text(left, top + 306, positionText, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '15px', fontSize: '14px',
color: acted ? '#bdbdbd' : '#9fb0bf' color: acted ? '#bdbdbd' : '#9fb0bf',
wordWrap: { width, useAdvancedWrap: true }
})); }));
this.renderEquipmentSummary(unit, left, top + 330, width); this.renderEquipmentSummary(unit, left, top + 330, width);
if (message) {
this.renderPanelMessage(message, left, top + 444, width, 66);
}
this.showFacingIndicator(unit);
} }
private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) { private renderEquipmentSummary(unit: UnitData, x: number, y: number, width: number) {
this.trackSideObject(this.add.text(x, y, '장비', { this.trackSideObject(this.add.text(x, y, '장비 / 숙련도', {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '16px', fontSize: '16px',
color: '#f2e3bf', color: '#f2e3bf',
@@ -10216,23 +10418,22 @@ export class BattleScene extends Phaser.Scene {
}, 0); }, 0);
} }
private renderSmallValueBox(x: number, y: number, label: string, value: string) { private renderCompactValueBox(x: number, y: number, width: number, label: string, value: string) {
const boxWidth = (this.layout.panelWidth - 60) / 2; const bg = this.trackSideObject(this.add.rectangle(x, y, width, 38, 0x16212d, 0.92));
const bg = this.trackSideObject(this.add.rectangle(x, y, boxWidth, 38, 0x16212d, 0.92));
bg.setOrigin(0); bg.setOrigin(0);
bg.setStrokeStyle(1, 0x53606c, 0.56); bg.setStrokeStyle(1, 0x53606c, 0.56);
this.trackSideObject(this.add.text(x + 12, y + 9, label, { this.trackSideObject(this.add.text(x + 8, y + 6, label, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '14px', fontSize: '12px',
color: '#9fb0bf' color: '#9fb0bf'
})); }));
const valueText = this.trackSideObject(this.add.text(x + boxWidth - 12, y + 7, value, { const valueText = this.trackSideObject(this.add.text(x + width - 8, y + 20, value, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: '18px', fontSize: '14px',
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700' fontStyle: '700'
})); }));
valueText.setOrigin(1, 0); valueText.setOrigin(1, 0.5);
} }
private renderStatRow(label: string, value: number, x: number, y: number, width: number) { private renderStatRow(label: string, value: number, x: number, y: number, width: number) {
@@ -10292,6 +10493,7 @@ export class BattleScene extends Phaser.Scene {
private clearSidePanelContent() { private clearSidePanelContent() {
this.sidePanelObjects.forEach((object) => object.destroy()); this.sidePanelObjects.forEach((object) => object.destroy());
this.sidePanelObjects = []; this.sidePanelObjects = [];
this.clearFacingIndicator();
} }
private trackSideObject<T extends Phaser.GameObjects.GameObject>(object: T) { private trackSideObject<T extends Phaser.GameObjects.GameObject>(object: T) {
@@ -10351,6 +10553,7 @@ export class BattleScene extends Phaser.Scene {
battleStats: this.serializeBattleStats(), battleStats: this.serializeBattleStats(),
triggeredBattleEvents: Array.from(this.triggeredBattleEvents), triggeredBattleEvents: Array.from(this.triggeredBattleEvents),
selectedUsable: this.selectedUsable?.id ?? null, selectedUsable: this.selectedUsable?.id ?? null,
audio: soundDirector.getDebugState(),
itemStocks: this.serializeItemStocks(), itemStocks: this.serializeItemStocks(),
battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })),
treasureEffectPreviews: this.debugTreasureEffectPreviews(), treasureEffectPreviews: this.debugTreasureEffectPreviews(),
@@ -10371,6 +10574,7 @@ export class BattleScene extends Phaser.Scene {
maxHp: unit.maxHp, maxHp: unit.maxHp,
x: unit.x, x: unit.x,
y: unit.y, y: unit.y,
direction: this.unitViews.get(unit.id)?.direction ?? null,
acted: this.actedUnitIds.has(unit.id), acted: this.actedUnitIds.has(unit.id),
animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false, animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false,
animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null