Refine battle performance and team limits

This commit is contained in:
Horoli 2026-05-26 17:54:41 +09:00
parent 743b2a75f5
commit 9df4f3dcde
15 changed files with 855 additions and 258 deletions

View File

@ -1,3 +1,31 @@
# Update: Team Size Constants
- The battle setup team-size limit is centralized in `SPAWN.MAX_TEAM_SIZE` inside `src/constants.js`.
- `matchForm.js` applies `SPAWN.DEFAULT_TEAM_SIZE` and `SPAWN.MAX_TEAM_SIZE` to the number/range controls at runtime, so `index.html` should not hardcode the team-size max.
- `matchSetup.js` clamps the requested base team size with `SPAWN.MAX_TEAM_SIZE` before applying any per-name `*N` multiplier.
# Update: Large Battle Performance
- Combat target acquisition now builds a per-frame spatial grid so every fighter that needs a fresh target can search nearby cells instead of scanning the full battlefield array.
- Large battle thresholds and related tuning live in `PERFORMANCE` inside `src/constants.js`, including target grid size, HUD pool size, minimap dot size, and large-battle corpse despawn delay.
- Fighter name/health HUD objects are pooled. Fighters no longer own permanent text/bar objects; selected and zoom-visible nearby fighters borrow HUD slots.
- The minimap is separated from the field camera. During live matches, `ArenaScene` draws a lightweight graphics minimap through a dedicated `minimap-hud` camera while the main camera ignores the minimap object and the HUD camera ignores field objects. Presentation/waiting mode hides the minimap.
- Dead fighter despawn switches to the large-battle delay when the current fighter count reaches `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.
# Update: Dead Fighter Despawn
- Dead fighters now keep their initial opacity at death, then fade out over `FIGHTER.DEAD_DESPAWN_DELAY_MS` before being removed.
- Adjust the corpse lifetime in `src/constants.js` by changing `FIGHTER.DEAD_DESPAWN_DELAY_MS`; adjust the final fade target with `FIGHTER.DEAD_DESPAWN_ALPHA`.
- Despawn uses the Phaser scene timer and a matching tween so pause/state cleanup follows the existing match lifecycle.
# Update: Team Shadow Rendering
- Team color is now represented by recoloring the built-in floor shadow pixels on each fighter spritesheet instead of rendering a duplicated `teamMarker` sprite.
- `fighterAssets.js` owns lazy team-shadow texture and animation generation for actual `skin + action + teamColor` combinations. Avoid pre-generating every team/skin/action combination because that can move the bottleneck into startup texture creation and memory use.
- `fighterFactory.js` should keep each fighter to one Phaser sprite. Name labels and health bars remain separate HUD objects, but there is no per-fighter team marker sprite to synchronize.
- `combat.js` must resolve action animations through `ensureFighterTeamAnimation()` so action changes keep the team-colored shadow.
- Frost stun uses body tint only. Do not use tint for persistent team identity.
# Agent: Arena Picker
## 0. 필수

View File

@ -1,3 +1,9 @@
# Update: Graphics Minimap And HUD Candidates
- The minimap is drawn during live matches by `ArenaScene` as a lightweight `Graphics` overlay through a dedicated `minimap-hud` camera instead of reusing the field camera. Presentation/waiting mode hides it.
- The main camera ignores the minimap graphics object, and the HUD camera ignores field objects as they are added to the scene. Minimap rendering uses team-colored living fighter dots and the main camera viewport rectangle, so the minimap frame stays fixed while the main camera follows combat or meteor focus.
- `ArenaScene` refreshes HUD candidates on an interval, choosing selected fighters plus nearby visible fighters when zoomed in, then releases unused HUD pool slots.
# Context: Arena & Scene
## 1. 모듈별 상세 역할 (`src/game/arena/`)

View File

@ -1,3 +1,19 @@
# Update: Large Battle Targeting
- `combat.js` now prepares a per-frame target spatial index through `prepareCombatFrame(scene)`.
- `resolveTargetEnemy()` keeps valid cached targets until their scan interval expires, then immediately looks up a fresh nearest enemy through the spatial grid.
- Nearest enemy lookup searches grid cells outward from the fighter's current cell, with full-array scanning kept only as a fallback when no frame index exists.
- Large-battle corpse cleanup uses `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` and `PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS` from `src/constants.js`.
# Update: Team Shadow Animations And Frost Tint
- Dead fighters keep their death animation/corpse state at initial opacity, then fade out until `combat.js` removes them from `scene.fighters` and destroys the sprite.
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
- `combat.js` resolves fighter animation keys through `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
- `playIfNeeded()` compares against the team-shadow animation key. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns the name label and health bar objects.
# Context: Combat System
## 1. 모듈별 상세 역할 (`src/game/combat/`)

View File

@ -1,5 +1,20 @@
# Context: Core & Infrastructure
# Update: Team Size Constants
- `SPAWN.MAX_TEAM_SIZE` is the single source of truth for the battle setup team-size maximum.
- `src/ui/matchForm.js` applies `SPAWN.DEFAULT_TEAM_SIZE` and `SPAWN.MAX_TEAM_SIZE` to the team-size number/range inputs at runtime.
- `src/game/match/matchSetup.js` clamps the requested base team size with `SPAWN.MAX_TEAM_SIZE`; per-name `*N` multipliers are applied after that clamp.
# Update: Performance Constants
- `src/constants.js` now exports `PERFORMANCE` for large-battle tuning: fighter threshold, target grid size, HUD pool/candidate limits, graphics minimap settings, and large-battle dead despawn delay.
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
# Update: Dead Fighter Despawn Constant
- `FIGHTER.DEAD_DESPAWN_DELAY_MS` controls how long a dead fighter fades before disappearing; `FIGHTER.DEAD_DESPAWN_ALPHA` controls the fade target.
## 1. 모듈별 상세 역할
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.

View File

@ -1,3 +1,18 @@
# Update: HUD Pooling
- `fighterFactory.js` no longer creates permanent name labels or health bars for every fighter.
- HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
- `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed.
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
# Update: Team Shadow Sprite Optimization
- Team identity is no longer rendered with a duplicated `teamMarker` sprite. `fighterFactory.js` now creates only the main Phaser sprite for each fighter.
- `fighterAssets.js` lazily creates team-colored spritesheets for the actual `skin + action + teamColor` combinations used in a match. The derived texture keeps the original character art and replaces only the floor shadow color (`#534545`) in the lower frame band (`y=55..59`) with the team color.
- Combat animation playback calls `ensureFighterTeamAnimation()` before switching actions, so idle, walk, attack, hurt, and death states keep the same team shadow treatment.
- This reduces display-list cost from `fighter sprite + teamMarker sprite` to a single fighter sprite. The tradeoff is extra texture memory for team-colored derivatives, so derived textures must stay lazy and should not be pre-generated for the whole manifest.
- Frost stun still uses the fighter body's `setTint(WORLD_EFFECT.FROST_STUN_TINT)`. Do not reuse tint for team identity; team color is baked into the shadow pixels instead.
# Context: Fighter & Assets
## 1. 모듈별 상세 역할 (`src/game/fighter/`)

View File

@ -185,7 +185,6 @@ Player 10</textarea
class="team-size-number"
type="number"
min="1"
max="100"
step="1"
value="5"
inputmode="numeric"
@ -197,7 +196,6 @@ Player 10</textarea
name="teamSize"
type="range"
min="1"
max="100"
value="5"
/>
<div class="spawn-placement-field">

View File

@ -14,7 +14,8 @@ export const FIGHTER = {
SCALE: 3,
DEPTH: 2,
DEAD_DEPTH: 1,
DEAD_ALPHA: 0.42,
DEAD_DESPAWN_ALPHA: 0,
DEAD_DESPAWN_DELAY_MS: 5000,
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
HITBOX_WIDTH: 22,
@ -72,6 +73,19 @@ export const FIGHTER = {
},
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 500,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 700,
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
FIGHTER_HUD_POOL_SIZE: 96,
FIGHTER_HUD_VISIBLE_LIMIT: 72,
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
MINIMAP_DOT_RADIUS: 3,
MINIMAP_BACKGROUND_ALPHA: 0.62,
MINIMAP_BORDER_ALPHA: 0.84,
};
// 3. SPAWN 도메인
export const SPAWN = {
DEFAULT_TEAM_SIZE: 5,
@ -80,13 +94,13 @@ export const SPAWN = {
RANDOM: "random",
STARTING_ZONES: "starting-zones",
},
STARTING_ZONE_RADIUS: 3,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
STARTING_ZONE_VISIBLE_DURATION_MS: 5000,
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
PRESENTATION_TEAM_COUNT: 10,
PRESENTATION_TEAM_SIZE: 5,
MAX_TEAM_SIZE: 100,
MAX_TEAM_SIZE: 50,
};
// 4. COMBAT 도메인

View File

@ -3,11 +3,12 @@ import {
ARENA,
CAMERA,
COMBAT,
PERFORMANCE,
SPAWN,
UI,
} from "../../constants.js";
import { drawArena, drawStartingZones } from "./arenaRenderer.js";
import { clearCombatObjects, updateFighter } from "../combat/combat.js";
import { clearCombatObjects, prepareCombatFrame, updateFighter } from "../combat/combat.js";
import {
clearWorldEffects,
createWorldEffectAnimations,
@ -16,7 +17,11 @@ import {
updateWorldEffectModifiers,
} from "../combat/worldEffects.js";
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
import {
createFighter,
releaseUnusedFighterHuds,
syncFighterHud,
} from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "../fighter/fighterSelection.js";
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
@ -90,6 +95,8 @@ export class ArenaScene extends Phaser.Scene {
this.deathStatsSaved = false;
this.finalFocusNextSwitchAt = 0;
this.finalFocusTarget = null;
this.fighterHudCandidates = [];
this.nextFighterHudCandidateRefreshAt = 0;
this.spectatorMode = null;
this.meteorFocusState = null;
this.slowMotionRestoreState = null;
@ -97,6 +104,8 @@ export class ArenaScene extends Phaser.Scene {
this.slowMotionTransitionFrame = null;
this.startingZoneGraphics = null;
this.startingZoneHideTimer = null;
this.minimapGraphics = null;
this.minimapHudCamera = null;
this.worldEffectTimer = null;
this.worldEffectZones = new Set();
}
@ -116,16 +125,16 @@ export class ArenaScene extends Phaser.Scene {
createWorldEffectAnimations(this);
// 미니맵 카메라 설정
this.minimapCamera = this.cameras
this.minimapGraphics = this.add.graphics().setDepth(10);
this.minimapHudCamera = this.cameras
.add(UI.MINIMAP_MARGIN, UI.MINIMAP_MARGIN, UI.MINIMAP_VIEWPORT_SIZE, UI.MINIMAP_VIEWPORT_SIZE)
.setZoom(UI.MINIMAP_VIEWPORT_SIZE / ARENA.SIZE)
.setName("minimap");
this.minimapCamera.setBackgroundColor(0x000000);
this.minimapCamera.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
this.minimapViewportFrame = this.add.graphics().setDepth(10);
this.cameras.main.ignore(this.minimapViewportFrame);
this.updateMinimapViewportFrame();
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
.setName("minimap-hud")
.setScroll(0, 0)
.setZoom(1);
this.cameras.main.ignore(this.minimapGraphics);
this.syncMinimapHudCameraIgnores();
this.events.on("addedtoscene", this.handleGameObjectAddedToScene, this);
this.updateMinimap();
// 마우스 휠로 줌 조절
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
const newZoom = Phaser.Math.Clamp(
@ -185,6 +194,8 @@ export class ArenaScene extends Phaser.Scene {
this.presentationMode = silent;
this.resetMatchDeathStats({ silent });
this.observedCombat = [];
this.fighterHudCandidates = [];
this.nextFighterHudCandidateRefreshAt = 0;
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
@ -388,14 +399,15 @@ export class ArenaScene extends Phaser.Scene {
};
}
update(time) {
this.fighters.forEach(syncFighterHud);
this.syncFighterHuds(time);
if (this.matchPaused) {
this.updateMinimapViewportFrame();
this.updateMinimap();
return;
}
if (!this.matchOver) {
prepareCombatFrame(this);
updateWorldEffectModifiers(this);
this.fighters.forEach((fighter) => {
@ -408,18 +420,17 @@ update(time) {
if (this.presentationMode) {
this.followPresentationCombat();
this.minimapCamera?.setAlpha(0);
this.updateMinimapViewportFrame();
this.updateMinimap();
return;
}
if (this.focusSelectedFighter()) {
this.updateMinimapViewportFrame();
this.updateMinimap();
return;
}
if (this.matchOver) {
this.updateMinimapViewportFrame();
this.updateMinimap();
return;
}
@ -439,7 +450,7 @@ update(time) {
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
}
this.updateMinimapViewportFrame();
this.updateMinimap();
}
syncSpectatorMode(mode) {
@ -761,13 +772,21 @@ update(time) {
this.observedCombat = [];
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM));
this.centerCameraOnFighter(fighter);
syncFighterHud(fighter);
syncFighterHud(fighter, {
force: true,
showDetails: true,
time: this.time.now,
});
}
clearSelectedFighter() {
if (this.selectedFighter) {
this.selectedFighter.isSelected = false;
syncFighterHud(this.selectedFighter);
syncFighterHud(this.selectedFighter, {
force: true,
showDetails: this.shouldShowFighterHudDetails(),
time: this.time.now,
});
}
this.selectedFighter = null;
@ -875,8 +894,7 @@ update(time) {
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
}
this.minimapCamera?.setAlpha(0);
this.updateMinimapViewportFrame();
this.updateMinimap();
}
followPresentationCombat() {
@ -904,63 +922,186 @@ update(time) {
this.observedCombat = [];
}
this.minimapCamera.setAlpha(newZoom > CAMERA.MIN_ZOOM ? UI.MINIMAP_ALPHA : 0);
this.updateMinimapViewportFrame();
this.updateMinimap();
}
updateMinimapViewportFrame() {
if (!this.minimapViewportFrame) {
handleGameObjectAddedToScene(gameObject) {
this.configureMinimapCameraVisibility(gameObject);
}
syncMinimapHudCameraIgnores() {
this.children.getChildren().forEach((gameObject) => {
this.configureMinimapCameraVisibility(gameObject);
});
}
configureMinimapCameraVisibility(gameObject) {
if (!gameObject || !this.minimapHudCamera) {
return;
}
if (gameObject === this.minimapGraphics) {
this.cameras.main.ignore(gameObject);
return;
}
this.minimapHudCamera.ignore(gameObject);
}
updateMinimap() {
if (!this.minimapGraphics) {
return;
}
const camera = this.cameras.main;
const graphics = this.minimapGraphics;
this.minimapViewportFrame.clear();
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA.MIN_ZOOM);
graphics.clear();
graphics.setVisible(!this.presentationMode);
if (camera.zoom <= CAMERA.MIN_ZOOM) {
if (this.presentationMode) {
return;
}
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA.SIZE));
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA.SIZE));
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
const cameraMidX = scrollX + camera.width / 2;
const cameraMidY = scrollY + camera.height / 2;
const frameX = Phaser.Math.Clamp(
this.snapMinimapFrameValue(cameraMidX - frameWidth / 2),
0,
ARENA.SIZE - frameWidth,
);
const frameY = Phaser.Math.Clamp(
this.snapMinimapFrameValue(cameraMidY - frameHeight / 2),
0,
ARENA.SIZE - frameHeight,
);
const x = 0;
const y = 0;
const size = UI.MINIMAP_VIEWPORT_SIZE;
const stroke = UI.MINIMAP_VIEW_FRAME_STROKE;
const dotRadius = PERFORMANCE.MINIMAP_DOT_RADIUS;
this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight);
graphics.setPosition(0, 0);
graphics.setScale(1);
graphics.fillStyle(0x000000, PERFORMANCE.MINIMAP_BACKGROUND_ALPHA);
graphics.fillRect(x, y, size, size);
graphics.lineStyle(stroke, 0xffe4a8, PERFORMANCE.MINIMAP_BORDER_ALPHA);
graphics.strokeRect(x, y, size, size);
this.drawMinimapFighterDots(graphics, x, y, size, dotRadius);
this.drawMinimapViewportFrame(graphics, x, y, size, stroke);
}
drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) {
const stroke = Math.min(UI.MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
const sideHeight = Math.max(0, frameHeight - stroke * 2);
drawMinimapFighterDots(graphics, x, y, size, dotRadius) {
const livingFighters = this.combatTargetIndex?.livingFighters
?? this.fighters.filter(isLivingFighter);
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke);
this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke);
this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight);
this.minimapViewportFrame.fillRect(
frameX + frameWidth - stroke,
frameY + stroke,
stroke,
sideHeight,
);
livingFighters.forEach((fighter) => {
const dotX = x + Phaser.Math.Clamp(fighter.x / ARENA.SIZE, 0, 1) * size;
const dotY = y + Phaser.Math.Clamp(fighter.y / ARENA.SIZE, 0, 1) * size;
graphics.fillStyle(this.minimapTeamColor(fighter.team), 0.9);
graphics.fillCircle(dotX, dotY, dotRadius);
});
}
snapMinimapFrameValue(value) {
const minimapZoom = this.minimapCamera?.zoom ?? 1;
return Math.round(value * minimapZoom) / minimapZoom;
drawMinimapViewportFrame(graphics, x, y, size, stroke) {
const camera = this.cameras.main;
const view = camera.worldView;
const frameX = x + Phaser.Math.Clamp(view.x / ARENA.SIZE, 0, 1) * size;
const frameY = y + Phaser.Math.Clamp(view.y / ARENA.SIZE, 0, 1) * size;
const frameWidth = Math.min(size, Math.max(stroke, (view.width / ARENA.SIZE) * size));
const frameHeight = Math.min(size, Math.max(stroke, (view.height / ARENA.SIZE) * size));
graphics.lineStyle(stroke, 0xffffff, 0.88);
graphics.strokeRect(frameX, frameY, frameWidth, frameHeight);
}
minimapTeamColor(team) {
if (!team) {
return 0xffffff;
}
if (team.minimapColor === undefined) {
const color = Number.parseInt(String(team.color).replace(/^#/, ""), 16);
team.minimapColor = Number.isFinite(color) ? color : 0xffffff;
}
return team.minimapColor;
}
syncFighterHuds(time) {
const candidates = this.resolveFighterHudCandidates(time);
releaseUnusedFighterHuds(this, candidates);
candidates.forEach((fighter) => {
syncFighterHud(fighter, {
showDetails: true,
time,
});
});
}
resolveFighterHudCandidates(time) {
const selectedFighter = isLivingFighter(this.selectedFighter) ? this.selectedFighter : null;
if (!this.shouldShowFighterHudDetails()) {
this.fighterHudCandidates = selectedFighter ? [selectedFighter] : [];
return this.fighterHudCandidates;
}
if (this.cameras.main.zoom <= CAMERA.MIN_ZOOM) {
this.fighterHudCandidates = selectedFighter ? [selectedFighter] : [];
return this.fighterHudCandidates;
}
const now = Number.isFinite(time) ? time : this.time.now;
if (now < this.nextFighterHudCandidateRefreshAt && this.fighterHudCandidates.length > 0) {
return this.ensureSelectedHudCandidate(this.fighterHudCandidates, selectedFighter);
}
this.nextFighterHudCandidateRefreshAt =
now + Math.max(0, PERFORMANCE.FIGHTER_HUD_CANDIDATE_REFRESH_MS);
const camera = this.cameras.main;
const viewPadding = Math.max(0, PERFORMANCE.FIGHTER_HUD_VIEW_PADDING);
const viewBounds = new Phaser.Geom.Rectangle(
camera.worldView.x - viewPadding,
camera.worldView.y - viewPadding,
camera.worldView.width + viewPadding * 2,
camera.worldView.height + viewPadding * 2,
);
const cameraCenter = camera.midPoint;
const candidates = [];
this.fighters.forEach((fighter) => {
if (!isLivingFighter(fighter) || !viewBounds.contains(fighter.x, fighter.y)) {
return;
}
const deltaX = fighter.x - cameraCenter.x;
const deltaY = fighter.y - cameraCenter.y;
candidates.push({
distance: deltaX * deltaX + deltaY * deltaY,
fighter,
});
});
candidates.sort((left, right) => left.distance - right.distance);
const limit = Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT));
this.fighterHudCandidates = this.ensureSelectedHudCandidate(
candidates.slice(0, limit).map((candidate) => candidate.fighter),
selectedFighter,
);
return this.fighterHudCandidates;
}
ensureSelectedHudCandidate(candidates, selectedFighter) {
const livingCandidates = candidates.filter(isLivingFighter);
if (selectedFighter && !livingCandidates.includes(selectedFighter)) {
return [selectedFighter, ...livingCandidates].slice(
0,
Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT)),
);
}
return livingCandidates;
}
shouldShowFighterHudDetails() {
return this.cameras.main.zoom > CAMERA.MIN_ZOOM || Boolean(this.selectedFighter);
}
observeCombat(attacker, defender) {

View File

@ -3,6 +3,7 @@ import {
ARENA,
FIGHTER,
COMBAT,
PERFORMANCE,
PROJECTILE,
} from "../../constants.js";
import {
@ -10,7 +11,7 @@ import {
getMovementSpeedMultiplier,
} from "./combatSettings.js";
import {
fighterAnimationKey,
ensureFighterTeamAnimation,
fighterAttackEffectAnimationKey,
fighterAttackEffectKey,
fighterProjectileKey,
@ -19,18 +20,33 @@ import {
} from "../fighter/fighterAssets.js";
import { getFighterStats } from "../fighter/fighterStats.js";
export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter);
const TARGET_SCAN_INTERVAL_MS = 180;
const TARGET_SCAN_JITTER_MS = 90;
if (!enemy || fighter.isDead || enemy.isDead || fighter.isFrostStunned || fighter.isLocked) {
fighter.body.setVelocity(0, 0);
export function prepareCombatFrame(scene) {
scene.combatTargetIndex = createTargetSpatialIndex(scene.fighters ?? []);
}
export function updateFighter(scene, fighter, time, onWinner) {
if (!fighter.active || fighter.isDead || fighter.isFrostStunned || fighter.isLocked) {
fighter.body?.setVelocity(0, 0);
return;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, enemy.x, enemy.y);
const enemy = resolveTargetEnemy(scene, fighter, time);
if (!enemy) {
fighter.body?.setVelocity(0, 0);
return;
}
const deltaX = fighter.x - enemy.x;
const deltaY = fighter.y - enemy.y;
const distance = deltaX * deltaX + deltaY * deltaY;
const attackRange = getAttackRange(fighter);
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
if (distance > attackRange * attackRange) {
scene.physics.moveToObject(
fighter,
enemy,
@ -397,14 +413,9 @@ function killFighter(defender, winner, onWinner) {
defender.isLocked = true;
defender.body.setVelocity(0, 0);
defender.body.enable = false;
defender.healthBar.width = 0;
defender.setAlpha(FIGHTER.DEAD_ALPHA);
defender.setDepth(FIGHTER.DEAD_DEPTH);
defender.disableInteractive();
defender.teamMarker?.setVisible(false);
defender.nameLabel?.setVisible(false);
defender.healthBack?.setVisible(false);
defender.healthBar?.setVisible(false);
defender.releaseHud?.();
playAnimation(defender, "death");
if (winner) {
@ -419,6 +430,68 @@ function killFighter(defender, winner, onWinner) {
maybeSplitFighter(defender);
onWinner?.(winner);
scheduleDeadFighterDespawn(defender);
}
function scheduleDeadFighterDespawn(fighter) {
const scene = fighter.scene;
const delay = resolveDeadDespawnDelay(scene);
const matchId = scene.matchId;
fighter.deadDespawnTimer?.remove(false);
fighter.deadDespawnTween?.remove();
fighter.deadDespawnTween = null;
const despawn = () => {
const despawnTween = fighter.deadDespawnTween;
fighter.deadDespawnTimer = null;
fighter.deadDespawnTween = null;
despawnTween?.remove();
if (!fighter.active || !fighter.isDead || scene.matchId !== matchId) {
return;
}
if (scene.selectedFighter === fighter) {
scene.clearSelectedFighter?.();
}
removeFighterFromBattlefield(scene, fighter);
fighter.destroy();
};
if (delay === 0) {
despawn();
return;
}
fighter.deadDespawnTween = scene.tweens.add({
targets: fighter,
alpha: FIGHTER.DEAD_DESPAWN_ALPHA,
duration: delay,
ease: "Sine.easeIn",
});
const timer = scene.time.delayedCall(delay, despawn);
fighter.deadDespawnTimer = timer;
fighter.once("destroy", () => {
if (fighter.deadDespawnTimer === timer) {
fighter.deadDespawnTimer = null;
}
timer.remove(false);
fighter.deadDespawnTween?.remove();
fighter.deadDespawnTween = null;
});
}
function removeFighterFromBattlefield(scene, fighter) {
if (!Array.isArray(scene.fighters)) {
return;
}
scene.fighters = scene.fighters.filter((candidate) => candidate !== fighter);
}
function maybeSplitFighter(fighter) {
@ -525,25 +598,72 @@ function spawnKillHealEffect(fighter, effectScale) {
});
}
function findNearestEnemy(fighters, fighter) {
function resolveDeadDespawnDelay(scene) {
const fighterCount = scene.fighters?.length ?? 0;
const isLargeBattle =
fighterCount >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD);
const delay = isLargeBattle
? PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS
: FIGHTER.DEAD_DESPAWN_DELAY_MS;
return Math.max(0, Number(delay) || 0);
}
function createTargetSpatialIndex(fighters) {
const cellSize = Math.max(1, Number(PERFORMANCE.TARGET_GRID_CELL_SIZE) || ARENA.TILE_SIZE);
const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize);
const maxCellY = Math.floor((ARENA.SIZE - 1) / cellSize);
const cells = new Map();
const livingFighters = [];
fighters.forEach((fighter) => {
if (!fighter?.active || fighter.isDead) {
return;
}
const cellX = clampCell(fighter.x, cellSize, maxCellX);
const cellY = clampCell(fighter.y, cellSize, maxCellY);
const key = targetCellKey(cellX, cellY);
const cell = cells.get(key) ?? [];
cell.push(fighter);
cells.set(key, cell);
livingFighters.push(fighter);
});
return {
cellSize,
cells,
livingCount: livingFighters.length,
livingFighters,
maxCellX,
maxCellY,
maxSearchRing: Math.max(maxCellX, maxCellY) + 1,
};
}
function findNearestEnemy(scene, fighter) {
const targetIndex = scene.combatTargetIndex;
if (!targetIndex) {
return findNearestEnemyByFullScan(scene.fighters ?? [], fighter);
}
return findNearestEnemyBySpatialIndex(targetIndex, fighter);
}
function findNearestEnemyByFullScan(fighters, fighter) {
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((candidate) => {
if (
candidate === fighter ||
candidate.isDead ||
candidate.team.id === fighter.team.id
) {
if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) {
return;
}
const distance = Phaser.Math.Distance.Between(
fighter.x,
fighter.y,
candidate.x,
candidate.y,
);
const deltaX = fighter.x - candidate.x;
const deltaY = fighter.y - candidate.y;
const distance = deltaX * deltaX + deltaY * deltaY;
if (distance < nearestDistance) {
nearestDistance = distance;
@ -554,17 +674,142 @@ function findNearestEnemy(fighters, fighter) {
return nearestEnemy;
}
function findNearestEnemyBySpatialIndex(targetIndex, fighter) {
const cellX = clampCell(fighter.x, targetIndex.cellSize, targetIndex.maxCellX);
const cellY = clampCell(fighter.y, targetIndex.cellSize, targetIndex.maxCellY);
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let ring = 0; ring <= targetIndex.maxSearchRing; ring += 1) {
forEachTargetCellInRing(targetIndex, cellX, cellY, ring, (candidate) => {
if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) {
return;
}
const deltaX = fighter.x - candidate.x;
const deltaY = fighter.y - candidate.y;
const distance = deltaX * deltaX + deltaY * deltaY;
if (distance < nearestDistance) {
nearestDistance = distance;
nearestEnemy = candidate;
}
});
if (
nearestEnemy
&& ring > 0
&& nearestDistance <= (ring * targetIndex.cellSize) ** 2
) {
break;
}
}
return nearestEnemy;
}
function forEachTargetCellInRing(targetIndex, cellX, cellY, ring, callback) {
if (ring === 0) {
forEachTargetCell(targetIndex, cellX, cellY, callback);
return;
}
for (let x = cellX - ring; x <= cellX + ring; x += 1) {
forEachTargetCell(targetIndex, x, cellY - ring, callback);
forEachTargetCell(targetIndex, x, cellY + ring, callback);
}
for (let y = cellY - ring + 1; y <= cellY + ring - 1; y += 1) {
forEachTargetCell(targetIndex, cellX - ring, y, callback);
forEachTargetCell(targetIndex, cellX + ring, y, callback);
}
}
function forEachTargetCell(targetIndex, cellX, cellY, callback) {
if (
cellX < 0
|| cellY < 0
|| cellX > targetIndex.maxCellX
|| cellY > targetIndex.maxCellY
) {
return;
}
const cell = targetIndex.cells.get(targetCellKey(cellX, cellY));
if (!cell) {
return;
}
cell.forEach(callback);
}
function targetCellKey(cellX, cellY) {
return `${cellX}:${cellY}`;
}
function clampCell(value, cellSize, maxCell) {
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
}
function resolveTargetEnemy(scene, fighter, time) {
const now = Number.isFinite(time) ? time : scene.time?.now ?? 0;
if (
isValidEnemyTarget(fighter, fighter.targetEnemy)
&& now < (fighter.nextTargetScanAt ?? 0)
) {
return fighter.targetEnemy;
}
const enemy = findNearestEnemy(scene, fighter) ?? null;
fighter.targetEnemy = enemy;
scheduleNextTargetScan(fighter, now);
return enemy;
}
function scheduleNextTargetScan(fighter, now) {
fighter.nextTargetScanAt =
now
+ TARGET_SCAN_INTERVAL_MS
+ Phaser.Math.Between(0, TARGET_SCAN_JITTER_MS);
}
function isValidEnemyTarget(fighter, candidate) {
return Boolean(
candidate?.active
&& !candidate.isDead
&& candidate.team?.id !== fighter.team?.id,
);
}
function playIfNeeded(fighter, action) {
const key = fighterAnimationKey(fighter.skin, action);
const key = resolveFighterAnimationKey(fighter, action);
if (fighter.anims.currentAnim?.key !== key) {
playAnimation(fighter, action);
playResolvedAnimation(fighter, key);
}
}
function playAnimation(fighter, action, timeScale = 1) {
playResolvedAnimation(fighter, resolveFighterAnimationKey(fighter, action), timeScale);
}
function playResolvedAnimation(fighter, key, timeScale = 1) {
fighter.anims.timeScale = timeScale;
fighter.play(fighterAnimationKey(fighter.skin, action), true);
fighter.play(key, true);
}
function resolveFighterAnimationKey(fighter, action) {
const key = ensureFighterTeamAnimation(
fighter.scene,
fighter.skin,
action,
fighter.team?.color,
);
return key;
}
function scaledAttackDelay(duration, fighter) {

View File

@ -388,7 +388,6 @@ function applyFrostStun(scene, fighter) {
fighter.isFrostStunned = true;
fighter.body?.setVelocity(0, 0);
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
fighter.teamMarker?.setTint(WORLD_EFFECT.FROST_STUN_TINT).setAlpha(1);
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
clearFrostStun(fighter);
});
@ -402,10 +401,6 @@ function clearFrostStun(fighter) {
if (fighter.active) {
fighter.clearTint();
}
if (fighter.teamMarker?.active) {
fighter.teamMarker.setTint(fighter.teamColor).setAlpha(0.8);
}
}
function activateFrostZone(scene, zone, marker) {

View File

@ -1,28 +1,29 @@
import {
FIGHTER,
COMBAT,
UI,
} from "../../constants.js";
const SOURCE_ALPHA_THRESHOLD = 8;
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
const HEAL_EFFECT_KEY = "kill-heal-effect";
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
const TEAM_SHADOW_SOURCE_COLOR = {
red: 0x53,
green: 0x45,
blue: 0x45,
};
const TEAM_SHADOW_FRAME_Y_START = 55;
const TEAM_SHADOW_FRAME_Y_END = 60;
export function fighterSheetKey(skin, action, teamColor) {
if (teamColor) {
return `${skin.key}-${action}-team-shadow-${normalizeTeamColorKey(teamColor)}`;
}
export function fighterSheetKey(skin, action) {
return `${skin.key}-${action}`;
}
export function fighterAnimationKey(skin, action) {
return `${fighterSheetKey(skin, action)}-anim`;
}
export function fighterOutlineSheetKey(skin, action) {
return `${fighterSheetKey(skin, action)}-outline`;
}
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
return `${sheetKey}-outline`;
export function fighterAnimationKey(skin, action, teamColor) {
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
}
export function fighterAttackEffectKey(skin) {
@ -82,8 +83,6 @@ export function createFighterAnimations(scene, skins) {
repeat,
});
}
createFighterOutlineSheet(scene, skin, action, animation.frames);
});
createAttackEffectAnimation(scene, skin);
@ -92,6 +91,47 @@ export function createFighterAnimations(scene, skins) {
createHealEffectAnimation(scene);
}
export function ensureFighterTeamAnimation(scene, skin, action, teamColor) {
const animation = skin.animations[action];
if (!animation || !teamColor) {
return fighterAnimationKey(skin, action);
}
const textureKey = fighterSheetKey(skin, action, teamColor);
if (
!scene.textures.exists(textureKey)
&& !createFighterTeamShadowSheet(scene, skin, action, animation.frames, teamColor)
) {
return fighterAnimationKey(skin, action);
}
const key = fighterAnimationKey(skin, action, teamColor);
if (!scene.anims.exists(key)) {
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(textureKey, {
start: 0,
end: animation.frames - 1,
}),
frameRate,
repeat,
});
}
return key;
}
export function ensureFighterTeamAnimations(scene, skin, teamColor, actions = []) {
actions.forEach((action) => {
ensureFighterTeamAnimation(scene, skin, action, teamColor);
});
}
function preloadCombatAssets(scene, skin) {
const projectile = skin.combat?.projectile;
const attackEffect = skin.combat?.attackEffect;
@ -149,18 +189,18 @@ function createHealEffectAnimation(scene) {
});
}
function createFighterOutlineSheet(scene, skin, action, frameCount) {
const key = fighterOutlineSheetKey(skin, action);
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
const key = fighterSheetKey(skin, action, teamColor);
if (scene.textures.exists(key)) {
return;
return true;
}
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
const sourceImage = sourceTexture?.getSourceImage?.();
if (!sourceImage) {
return;
return false;
}
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
@ -172,90 +212,60 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
sourceContext.drawImage(sourceImage, 0, 0);
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
const outlineCanvas = document.createElement("canvas");
outlineCanvas.width = sheetWidth;
outlineCanvas.height = sheetHeight;
const outlineContext = outlineCanvas.getContext("2d");
const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight);
const outlineData = outlineImage.data;
const gapMask = new Uint8Array(sheetWidth * sheetHeight);
const outerMask = new Uint8Array(sheetWidth * sheetHeight);
const outlineAlpha = Math.round(UI.SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
const sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
const sourceData = sourceImageData.data;
const shadowColor = parseHexColor(teamColor);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameLeft = frameIndex * FIGHTER.FRAME_WIDTH;
for (let y = 0; y < FIGHTER.FRAME_HEIGHT; y += 1) {
for (let y = TEAM_SHADOW_FRAME_Y_START; y < TEAM_SHADOW_FRAME_Y_END; y += 1) {
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
continue;
}
markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y);
sourceData[sourceIndex] = shadowColor.red;
sourceData[sourceIndex + 1] = shadowColor.green;
sourceData[sourceIndex + 2] = shadowColor.blue;
}
}
}
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
outlineContext.putImageData(outlineImage, 0, 0);
scene.textures.addSpriteSheet(key, outlineCanvas, {
sourceContext.putImageData(sourceImageData, 0, 0);
scene.textures.addSpriteSheet(key, sourceCanvas, {
frameWidth: FIGHTER.FRAME_WIDTH,
frameHeight: FIGHTER.FRAME_HEIGHT,
});
return true;
}
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
const outerRadius = UI.SELECTED_FIGHTER_OUTLINE_GAP + UI.SELECTED_FIGHTER_OUTLINE_WIDTH;
for (
let offsetY = -outerRadius;
offsetY <= outerRadius;
offsetY += 1
) {
const targetY = sourceY + offsetY;
if (targetY < 0 || targetY >= FIGHTER.FRAME_HEIGHT) {
continue;
}
for (
let offsetX = -outerRadius;
offsetX <= outerRadius;
offsetX += 1
) {
const targetX = sourceX + offsetX;
if (targetX < 0 || targetX >= FIGHTER.FRAME_WIDTH) {
continue;
}
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
outerMask[maskIndex] = 1;
if (distance <= UI.SELECTED_FIGHTER_OUTLINE_GAP) {
gapMask[maskIndex] = 1;
}
}
}
function isTeamShadowPixel(data, index) {
return (
data[index + 3] > 0
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
);
}
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
continue;
}
const outlineIndex = maskIndex * 4;
outlineData[outlineIndex] = 255;
outlineData[outlineIndex + 1] = 255;
outlineData[outlineIndex + 2] = 255;
outlineData[outlineIndex + 3] = outlineAlpha;
}
function normalizeTeamColorKey(teamColor) {
return parseHexColor(teamColor).hex;
}
function parseHexColor(teamColor) {
const fallback = "ffffff";
const hex = typeof teamColor === "string"
? teamColor.trim().replace(/^#/, "").toLowerCase()
: fallback;
const normalizedHex = /^[0-9a-f]{6}$/.test(hex) ? hex : fallback;
return {
blue: parseInt(normalizedHex.slice(4, 6), 16),
green: parseInt(normalizedHex.slice(2, 4), 16),
hex: normalizedHex,
red: parseInt(normalizedHex.slice(0, 2), 16),
};
}

View File

@ -1,22 +1,29 @@
import Phaser from "phaser";
import {
FIGHTER,
PERFORMANCE,
} from "../../constants.js";
import {
fighterAnimationKey,
fighterOutlineSheetKeyFromSheetKey,
ensureFighterTeamAnimation,
ensureFighterTeamAnimations,
fighterSheetKey,
} from "./fighterAssets.js";
import { getFighterStats } from "./fighterStats.js";
const NAME_LABEL_BOTTOM_GAP = 14;
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
export function createFighter(
scene,
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
const teamIdleSheetKey = fighterSheetKey(skin, "idle", team.color);
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
? teamIdleSheetKey
: fighterSheetKey(skin, "idle");
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
const displayName = name || team.label;
const combatStats = getFighterStats(skin);
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
@ -44,38 +51,10 @@ export function createFighter(
);
fighter.input.cursor = "pointer";
fighter.teamMarker = scene.add
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
.setDisplaySize(FIGHTER.FRAME_WIDTH * FIGHTER.SCALE, FIGHTER.FRAME_HEIGHT * FIGHTER.SCALE)
.setTint(teamColor)
.setAlpha(0.8)
.setDepth(1.9)
.setVisible(true);
fighter.nameLabel = scene.add
.text(x, y, displayName, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: team.color,
strokeThickness: 4,
})
.setOrigin(0.5, 0)
.setDepth(4);
fighter.healthBack = scene.add
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
.setDepth(4);
fighter.healthBar = scene.add
.rectangle(x - 34, y - 44, 68, 4, 0xd95f3f, 1)
.setOrigin(0, 0.5)
.setDepth(5);
fighter.skin = skin;
fighter.combatStats = combatStats;
fighter.fighterName = displayName;
fighter.team = team;
fighter.teamColor = teamColor;
fighter.teamIndex = teamIndex;
fighter.baseScaleX = FIGHTER.SCALE;
fighter.baseScaleY = FIGHTER.SCALE;
@ -89,84 +68,201 @@ export function createFighter(
fighter.maxHp = resolvedMaxHp;
fighter.hp = resolvedHp;
fighter.nextAttackAt = 0;
fighter.nextHudSyncAt = 0;
fighter.nextTargetScanAt = 0;
fighter.targetEnemy = null;
fighter._hudDetailsVisible = false;
fighter._hudSlot = null;
fighter.isLocked = false;
fighter.isDead = false;
fighter.play(fighterAnimationKey(skin, "walk"));
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
if (fighter.isDead) {
return;
}
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
fighter.isLocked = false;
}
});
fighter.releaseHud = () => releaseFighterHud(fighter);
attachHudCleanup(fighter);
syncFighterHud(fighter);
return fighter;
}
export function syncFighterHud(fighter) {
export function syncFighterHud(
fighter,
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
) {
const isVisible = Boolean(fighter.active && !fighter.isDead);
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
fighter.nameLabel.setVisible(isVisible);
fighter.healthBack.setVisible(isVisible);
fighter.healthBar.setVisible(isVisible);
syncTeamMarker(fighter);
if (!isVisible || !fighter.body) {
return;
if (!detailsVisible || !fighter.body) {
releaseFighterHud(fighter);
return false;
}
const hudSlot = acquireFighterHudSlot(fighter);
if (!hudSlot) {
return false;
}
const now = Number.isFinite(time) ? time : fighter.scene?.time?.now ?? 0;
const shouldSyncDetails =
force
|| fighter.isSelected
|| !fighter._hudDetailsVisible
|| fighter._lastHudHp !== fighter.hp
|| now >= (fighter.nextHudSyncAt ?? 0);
if (!shouldSyncDetails) {
return true;
}
fighter.nextHudSyncAt = now + HUD_DETAIL_SYNC_INTERVAL_MS;
fighter._lastHudHp = fighter.hp;
setHudDetailsVisible(fighter, true);
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
const healthOffset = 44 * scaleRatio;
const hitbox = fighter.body;
const nameX = hitbox.x + hitbox.width / 2;
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
fighter.nameLabel.setPosition(nameX, nameY);
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
hudSlot.nameLabel.setPosition(nameX, nameY);
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
return true;
}
function syncTeamMarker(fighter) {
const marker = fighter.teamMarker;
export function releaseFighterHud(fighter) {
const hudSlot = fighter?._hudSlot;
if (!marker) {
if (!hudSlot) {
if (fighter) {
fighter._hudDetailsVisible = false;
}
return;
}
const isVisible = Boolean(fighter.active && !fighter.isDead);
marker.setVisible(isVisible);
setHudSlotVisible(hudSlot, false);
hudSlot.fighter = null;
fighter._hudSlot = null;
fighter._hudDetailsVisible = false;
}
if (!isVisible) {
export function releaseUnusedFighterHuds(scene, fightersWithHud = []) {
const activeFighters = fightersWithHud instanceof Set
? fightersWithHud
: new Set(fightersWithHud);
scene.fighterHudPool?.forEach((hudSlot) => {
if (hudSlot.fighter && !activeFighters.has(hudSlot.fighter)) {
releaseFighterHud(hudSlot.fighter);
}
});
}
function setHudDetailsVisible(fighter, visible) {
const hudSlot = fighter._hudSlot;
if (!hudSlot) {
return;
}
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
fighter._hudDetailsVisible = visible;
setHudSlotVisible(hudSlot, visible);
}
if (fighter.scene.textures.exists(outlineTextureKey)) {
marker.setTexture(outlineTextureKey, fighter.frame.name);
function setVisibleIfChanged(gameObject, visible) {
if (gameObject && gameObject.visible !== visible) {
gameObject.setVisible(visible);
}
}
function setHudSlotVisible(hudSlot, visible) {
setVisibleIfChanged(hudSlot.nameLabel, visible);
setVisibleIfChanged(hudSlot.healthBack, visible);
setVisibleIfChanged(hudSlot.healthBar, visible);
}
function acquireFighterHudSlot(fighter) {
if (fighter._hudSlot) {
return fighter._hudSlot;
}
marker.setPosition(fighter.x, fighter.y);
marker.setScale(fighter.scaleX, fighter.scaleY);
marker.setFlipX(fighter.flipX);
marker.setDepth(fighter.depth - 0.1);
const hudPool = ensureFighterHudPool(fighter.scene);
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
if (!hudSlot) {
return null;
}
hudSlot.fighter = fighter;
fighter._hudSlot = hudSlot;
configureHudSlot(hudSlot, fighter);
return hudSlot;
}
function ensureFighterHudPool(scene) {
if (scene.fighterHudPool) {
return scene.fighterHudPool;
}
const poolSize = Math.max(0, Math.round(PERFORMANCE.FIGHTER_HUD_POOL_SIZE));
scene.fighterHudPool = Array.from({ length: poolSize }, () => createHudSlot(scene));
return scene.fighterHudPool;
}
function createHudSlot(scene) {
const nameLabel = scene.add
.text(0, 0, "", {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: "#17180e",
strokeThickness: 4,
})
.setOrigin(0.5, 0)
.setDepth(4)
.setVisible(false);
const healthBack = scene.add
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
.setDepth(4)
.setVisible(false);
const healthBar = scene.add
.rectangle(0, 0, 68, 4, 0xd95f3f, 1)
.setOrigin(0, 0.5)
.setDepth(5)
.setVisible(false);
return {
fighter: null,
healthBack,
healthBar,
nameLabel,
};
}
function configureHudSlot(hudSlot, fighter) {
hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? "");
hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4);
hudSlot.nameLabel.setDepth(4);
hudSlot.healthBack.setDepth(4);
hudSlot.healthBar.setDepth(5);
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
fighter.teamMarker.destroy();
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();
releaseFighterHud(fighter);
originalDestroy(...args);
};
}

View File

@ -7,7 +7,7 @@ export function createMatchSetup(
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) {
const baseTeamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
const baseTeamSize = resolveTeamSize(requestedTeamSize);
const teams = names.map((rawName, index) => {
const match = rawName.match(NAME_MULTIPLIER_REGEX);
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
@ -213,18 +213,12 @@ function startingZonesOverlap(left, right) {
);
}
function resolveTeamSize(playerCount, requestedTeamSize) {
const teamSize = clamp(
function resolveTeamSize(requestedTeamSize) {
return clamp(
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
1,
SPAWN.MAX_TEAM_SIZE,
);
if (playerCount <= teamSize) {
return Math.max(1, Math.ceil(playerCount / 2));
}
return teamSize;
}
function spawnJitter() {

View File

@ -16,6 +16,8 @@ export function createMatchForm() {
const teamSizeInput = getElement("#team-size");
const teamSizeNumberInput = getElement("#team-size-value");
applyTeamSizeInputLimits(teamSizeInput, teamSizeNumberInput);
const readMatchConfig = () => ({
names: nicknameValues(namesInput.value),
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
@ -100,6 +102,15 @@ function nicknameValues(value) {
.filter(Boolean);
}
function applyTeamSizeInputLimits(...inputs) {
inputs.forEach((input) => {
input.min = "1";
input.max = String(SPAWN.MAX_TEAM_SIZE);
input.step = "1";
input.value = String(SPAWN.DEFAULT_TEAM_SIZE);
});
}
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);

13
todo.md
View File

@ -272,3 +272,16 @@
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.
44. Team marker duplicated sprite removal and team shadow baking (completed)
- **Changes**:
- Removed the duplicated per-fighter `teamMarker` Phaser sprite and its frame/position/depth synchronization path.
- Added lazy team-colored spritesheet and animation generation in `fighterAssets.js` by recoloring floor shadow pixels (`#534545`) to the fighter team color.
- Updated fighter creation and combat animation playback to use team-shadow animation keys for idle, walk, attack, hurt, and death actions.
- Kept frost stun as a body `setTint(WORLD_EFFECT.FROST_STUN_TINT)` effect, with no team-marker tint state to restore.
- Verified production build with `npm run build`.
45. Dead fighter battlefield despawn (completed)
- **Changes**:
- Added `FIGHTER.DEAD_DESPAWN_DELAY_MS` in `src/constants.js` so corpse lifetime is easy to tune.
- Updated `combat.js` to keep a dead fighter at initial opacity, fade it toward `FIGHTER.DEAD_DESPAWN_ALPHA`, then remove it from `scene.fighters` and destroy the sprite after the configured delay.
- Kept death bookkeeping, kill rewards, split-on-death, and winner checks ahead of the despawn schedule.