From 9df4f3dcde8176f13853917aba99d9beb782c5f9 Mon Sep 17 00:00:00 2001 From: Horoli Date: Tue, 26 May 2026 17:54:41 +0900 Subject: [PATCH] Refine battle performance and team limits --- agent.md | 28 +++ context/arena.md | 6 + context/combat.md | 16 ++ context/core.md | 15 ++ context/fighter.md | 15 ++ index.html | 2 - src/constants.js | 22 ++- src/game/arena/ArenaScene.js | 265 +++++++++++++++++++------ src/game/combat/combat.js | 301 ++++++++++++++++++++++++++--- src/game/combat/worldEffects.js | 5 - src/game/fighter/fighterAssets.js | 178 +++++++++-------- src/game/fighter/fighterFactory.js | 224 +++++++++++++++------ src/game/match/matchSetup.js | 12 +- src/ui/matchForm.js | 11 ++ todo.md | 13 ++ 15 files changed, 855 insertions(+), 258 deletions(-) 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.