diff --git a/agent.md b/agent.md
index 29cc7a2..2d85314 100644
--- a/agent.md
+++ b/agent.md
@@ -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. 필수
diff --git a/context/arena.md b/context/arena.md
index b3ed993..9274034 100644
--- a/context/arena.md
+++ b/context/arena.md
@@ -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/`)
diff --git a/context/combat.md b/context/combat.md
index 549f73f..bc3fd55 100644
--- a/context/combat.md
+++ b/context/combat.md
@@ -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/`)
diff --git a/context/core.md b/context/core.md
index 8e68502..806e7cd 100644
--- a/context/core.md
+++ b/context/core.md
@@ -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`을 인스턴스화합니다.
diff --git a/context/fighter.md b/context/fighter.md
index 3d49f3b..f979169 100644
--- a/context/fighter.md
+++ b/context/fighter.md
@@ -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/`)
diff --git a/index.html b/index.html
index ba63fd1..5ad0251 100644
--- a/index.html
+++ b/index.html
@@ -185,7 +185,6 @@ Player 10
diff --git a/src/constants.js b/src/constants.js
index cc665d1..542c2ec 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -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 도메인
diff --git a/src/game/arena/ArenaScene.js b/src/game/arena/ArenaScene.js
index 2185a92..051bd48 100644
--- a/src/game/arena/ArenaScene.js
+++ b/src/game/arena/ArenaScene.js
@@ -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) {
diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js
index e051b56..86ba52f 100644
--- a/src/game/combat/combat.js
+++ b/src/game/combat/combat.js
@@ -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) {
diff --git a/src/game/combat/worldEffects.js b/src/game/combat/worldEffects.js
index 8bceafa..b27d2d3 100644
--- a/src/game/combat/worldEffects.js
+++ b/src/game/combat/worldEffects.js
@@ -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) {
diff --git a/src/game/fighter/fighterAssets.js b/src/game/fighter/fighterAssets.js
index 426e2a1..059a1c2 100644
--- a/src/game/fighter/fighterAssets.js
+++ b/src/game/fighter/fighterAssets.js
@@ -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),
+ };
}
diff --git a/src/game/fighter/fighterFactory.js b/src/game/fighter/fighterFactory.js
index ee69adf..d619a87 100644
--- a/src/game/fighter/fighterFactory.js
+++ b/src/game/fighter/fighterFactory.js
@@ -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);
};
}
diff --git a/src/game/match/matchSetup.js b/src/game/match/matchSetup.js
index 50673a7..4858279 100644
--- a/src/game/match/matchSetup.js
+++ b/src/game/match/matchSetup.js
@@ -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() {
diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js
index ba48bfe..c42d215 100644
--- a/src/ui/matchForm.js
+++ b/src/ui/matchForm.js
@@ -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);
diff --git a/todo.md b/todo.md
index f0c48ee..b40aae7 100644
--- a/todo.md
+++ b/todo.md
@@ -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.