Refine battle performance and team limits
This commit is contained in:
parent
743b2a75f5
commit
9df4f3dcde
28
agent.md
28
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
|
# Agent: Arena Picker
|
||||||
|
|
||||||
## 0. 필수
|
## 0. 필수
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Context: Arena & Scene
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Context: Combat System
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,20 @@
|
||||||
# Context: Core & Infrastructure
|
# 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. 모듈별 상세 역할
|
## 1. 모듈별 상세 역할
|
||||||
|
|
||||||
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Context: Fighter & Assets
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,6 @@ Player 10</textarea
|
||||||
class="team-size-number"
|
class="team-size-number"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
|
||||||
step="1"
|
step="1"
|
||||||
value="5"
|
value="5"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
|
|
@ -197,7 +196,6 @@ Player 10</textarea
|
||||||
name="teamSize"
|
name="teamSize"
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
|
||||||
value="5"
|
value="5"
|
||||||
/>
|
/>
|
||||||
<div class="spawn-placement-field">
|
<div class="spawn-placement-field">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ export const FIGHTER = {
|
||||||
SCALE: 3,
|
SCALE: 3,
|
||||||
DEPTH: 2,
|
DEPTH: 2,
|
||||||
DEAD_DEPTH: 1,
|
DEAD_DEPTH: 1,
|
||||||
DEAD_ALPHA: 0.42,
|
DEAD_DESPAWN_ALPHA: 0,
|
||||||
|
DEAD_DESPAWN_DELAY_MS: 5000,
|
||||||
FRAME_WIDTH: 100,
|
FRAME_WIDTH: 100,
|
||||||
FRAME_HEIGHT: 100,
|
FRAME_HEIGHT: 100,
|
||||||
HITBOX_WIDTH: 22,
|
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 도메인
|
// 3. SPAWN 도메인
|
||||||
export const SPAWN = {
|
export const SPAWN = {
|
||||||
DEFAULT_TEAM_SIZE: 5,
|
DEFAULT_TEAM_SIZE: 5,
|
||||||
|
|
@ -80,13 +94,13 @@ export const SPAWN = {
|
||||||
RANDOM: "random",
|
RANDOM: "random",
|
||||||
STARTING_ZONES: "starting-zones",
|
STARTING_ZONES: "starting-zones",
|
||||||
},
|
},
|
||||||
STARTING_ZONE_RADIUS: 3,
|
STARTING_ZONE_RADIUS: 2,
|
||||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||||
STARTING_ZONE_VISIBLE_DURATION_MS: 5000,
|
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
||||||
PRESENTATION_TEAM_COUNT: 10,
|
PRESENTATION_TEAM_COUNT: 10,
|
||||||
PRESENTATION_TEAM_SIZE: 5,
|
PRESENTATION_TEAM_SIZE: 5,
|
||||||
MAX_TEAM_SIZE: 100,
|
MAX_TEAM_SIZE: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. COMBAT 도메인
|
// 4. COMBAT 도메인
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import {
|
||||||
ARENA,
|
ARENA,
|
||||||
CAMERA,
|
CAMERA,
|
||||||
COMBAT,
|
COMBAT,
|
||||||
|
PERFORMANCE,
|
||||||
SPAWN,
|
SPAWN,
|
||||||
UI,
|
UI,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import { drawArena, drawStartingZones } from "./arenaRenderer.js";
|
import { drawArena, drawStartingZones } from "./arenaRenderer.js";
|
||||||
import { clearCombatObjects, updateFighter } from "../combat/combat.js";
|
import { clearCombatObjects, prepareCombatFrame, updateFighter } from "../combat/combat.js";
|
||||||
import {
|
import {
|
||||||
clearWorldEffects,
|
clearWorldEffects,
|
||||||
createWorldEffectAnimations,
|
createWorldEffectAnimations,
|
||||||
|
|
@ -16,7 +17,11 @@ import {
|
||||||
updateWorldEffectModifiers,
|
updateWorldEffectModifiers,
|
||||||
} from "../combat/worldEffects.js";
|
} from "../combat/worldEffects.js";
|
||||||
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.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 { fighterManifest } from "../fighter/fighterManifest.js";
|
||||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||||
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
||||||
|
|
@ -90,6 +95,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.deathStatsSaved = false;
|
this.deathStatsSaved = false;
|
||||||
this.finalFocusNextSwitchAt = 0;
|
this.finalFocusNextSwitchAt = 0;
|
||||||
this.finalFocusTarget = null;
|
this.finalFocusTarget = null;
|
||||||
|
this.fighterHudCandidates = [];
|
||||||
|
this.nextFighterHudCandidateRefreshAt = 0;
|
||||||
this.spectatorMode = null;
|
this.spectatorMode = null;
|
||||||
this.meteorFocusState = null;
|
this.meteorFocusState = null;
|
||||||
this.slowMotionRestoreState = null;
|
this.slowMotionRestoreState = null;
|
||||||
|
|
@ -97,6 +104,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.slowMotionTransitionFrame = null;
|
this.slowMotionTransitionFrame = null;
|
||||||
this.startingZoneGraphics = null;
|
this.startingZoneGraphics = null;
|
||||||
this.startingZoneHideTimer = null;
|
this.startingZoneHideTimer = null;
|
||||||
|
this.minimapGraphics = null;
|
||||||
|
this.minimapHudCamera = null;
|
||||||
this.worldEffectTimer = null;
|
this.worldEffectTimer = null;
|
||||||
this.worldEffectZones = new Set();
|
this.worldEffectZones = new Set();
|
||||||
}
|
}
|
||||||
|
|
@ -116,16 +125,16 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
createWorldEffectAnimations(this);
|
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)
|
.add(UI.MINIMAP_MARGIN, UI.MINIMAP_MARGIN, UI.MINIMAP_VIEWPORT_SIZE, UI.MINIMAP_VIEWPORT_SIZE)
|
||||||
.setZoom(UI.MINIMAP_VIEWPORT_SIZE / ARENA.SIZE)
|
.setName("minimap-hud")
|
||||||
.setName("minimap");
|
.setScroll(0, 0)
|
||||||
this.minimapCamera.setBackgroundColor(0x000000);
|
.setZoom(1);
|
||||||
this.minimapCamera.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
this.cameras.main.ignore(this.minimapGraphics);
|
||||||
this.minimapViewportFrame = this.add.graphics().setDepth(10);
|
this.syncMinimapHudCameraIgnores();
|
||||||
this.cameras.main.ignore(this.minimapViewportFrame);
|
this.events.on("addedtoscene", this.handleGameObjectAddedToScene, this);
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimap();
|
||||||
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
|
||||||
// 마우스 휠로 줌 조절
|
// 마우스 휠로 줌 조절
|
||||||
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
||||||
const newZoom = Phaser.Math.Clamp(
|
const newZoom = Phaser.Math.Clamp(
|
||||||
|
|
@ -185,6 +194,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.presentationMode = silent;
|
this.presentationMode = silent;
|
||||||
this.resetMatchDeathStats({ silent });
|
this.resetMatchDeathStats({ silent });
|
||||||
this.observedCombat = [];
|
this.observedCombat = [];
|
||||||
|
this.fighterHudCandidates = [];
|
||||||
|
this.nextFighterHudCandidateRefreshAt = 0;
|
||||||
this.clearSelectedFighter();
|
this.clearSelectedFighter();
|
||||||
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
|
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
|
||||||
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
||||||
|
|
@ -388,14 +399,15 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
update(time) {
|
update(time) {
|
||||||
this.fighters.forEach(syncFighterHud);
|
this.syncFighterHuds(time);
|
||||||
|
|
||||||
if (this.matchPaused) {
|
if (this.matchPaused) {
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.matchOver) {
|
if (!this.matchOver) {
|
||||||
|
prepareCombatFrame(this);
|
||||||
updateWorldEffectModifiers(this);
|
updateWorldEffectModifiers(this);
|
||||||
|
|
||||||
this.fighters.forEach((fighter) => {
|
this.fighters.forEach((fighter) => {
|
||||||
|
|
@ -408,18 +420,17 @@ update(time) {
|
||||||
|
|
||||||
if (this.presentationMode) {
|
if (this.presentationMode) {
|
||||||
this.followPresentationCombat();
|
this.followPresentationCombat();
|
||||||
this.minimapCamera?.setAlpha(0);
|
this.updateMinimap();
|
||||||
this.updateMinimapViewportFrame();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.focusSelectedFighter()) {
|
if (this.focusSelectedFighter()) {
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.matchOver) {
|
if (this.matchOver) {
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,7 +450,7 @@ update(time) {
|
||||||
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateMinimapViewportFrame();
|
this.updateMinimap();
|
||||||
}
|
}
|
||||||
|
|
||||||
syncSpectatorMode(mode) {
|
syncSpectatorMode(mode) {
|
||||||
|
|
@ -761,13 +772,21 @@ update(time) {
|
||||||
this.observedCombat = [];
|
this.observedCombat = [];
|
||||||
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM));
|
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM));
|
||||||
this.centerCameraOnFighter(fighter);
|
this.centerCameraOnFighter(fighter);
|
||||||
syncFighterHud(fighter);
|
syncFighterHud(fighter, {
|
||||||
|
force: true,
|
||||||
|
showDetails: true,
|
||||||
|
time: this.time.now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedFighter() {
|
clearSelectedFighter() {
|
||||||
if (this.selectedFighter) {
|
if (this.selectedFighter) {
|
||||||
this.selectedFighter.isSelected = false;
|
this.selectedFighter.isSelected = false;
|
||||||
syncFighterHud(this.selectedFighter);
|
syncFighterHud(this.selectedFighter, {
|
||||||
|
force: true,
|
||||||
|
showDetails: this.shouldShowFighterHudDetails(),
|
||||||
|
time: this.time.now,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedFighter = null;
|
this.selectedFighter = null;
|
||||||
|
|
@ -875,8 +894,7 @@ update(time) {
|
||||||
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
|
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.minimapCamera?.setAlpha(0);
|
this.updateMinimap();
|
||||||
this.updateMinimapViewportFrame();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
followPresentationCombat() {
|
followPresentationCombat() {
|
||||||
|
|
@ -904,63 +922,186 @@ update(time) {
|
||||||
this.observedCombat = [];
|
this.observedCombat = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.minimapCamera.setAlpha(newZoom > CAMERA.MIN_ZOOM ? UI.MINIMAP_ALPHA : 0);
|
this.updateMinimap();
|
||||||
this.updateMinimapViewportFrame();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMinimapViewportFrame() {
|
handleGameObjectAddedToScene(gameObject) {
|
||||||
if (!this.minimapViewportFrame) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const camera = this.cameras.main;
|
const camera = this.cameras.main;
|
||||||
|
const graphics = this.minimapGraphics;
|
||||||
|
|
||||||
this.minimapViewportFrame.clear();
|
graphics.clear();
|
||||||
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA.MIN_ZOOM);
|
graphics.setVisible(!this.presentationMode);
|
||||||
|
|
||||||
if (camera.zoom <= CAMERA.MIN_ZOOM) {
|
if (this.presentationMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA.SIZE));
|
const x = 0;
|
||||||
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA.SIZE));
|
const y = 0;
|
||||||
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
|
const size = UI.MINIMAP_VIEWPORT_SIZE;
|
||||||
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
|
const stroke = UI.MINIMAP_VIEW_FRAME_STROKE;
|
||||||
const cameraMidX = scrollX + camera.width / 2;
|
const dotRadius = PERFORMANCE.MINIMAP_DOT_RADIUS;
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
drawMinimapFighterDots(graphics, x, y, size, dotRadius) {
|
||||||
const stroke = Math.min(UI.MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
|
const livingFighters = this.combatTargetIndex?.livingFighters
|
||||||
const sideHeight = Math.max(0, frameHeight - stroke * 2);
|
?? this.fighters.filter(isLivingFighter);
|
||||||
|
|
||||||
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
|
livingFighters.forEach((fighter) => {
|
||||||
this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke);
|
const dotX = x + Phaser.Math.Clamp(fighter.x / ARENA.SIZE, 0, 1) * size;
|
||||||
this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke);
|
const dotY = y + Phaser.Math.Clamp(fighter.y / ARENA.SIZE, 0, 1) * size;
|
||||||
this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight);
|
|
||||||
this.minimapViewportFrame.fillRect(
|
graphics.fillStyle(this.minimapTeamColor(fighter.team), 0.9);
|
||||||
frameX + frameWidth - stroke,
|
graphics.fillCircle(dotX, dotY, dotRadius);
|
||||||
frameY + stroke,
|
});
|
||||||
stroke,
|
|
||||||
sideHeight,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
snapMinimapFrameValue(value) {
|
drawMinimapViewportFrame(graphics, x, y, size, stroke) {
|
||||||
const minimapZoom = this.minimapCamera?.zoom ?? 1;
|
const camera = this.cameras.main;
|
||||||
return Math.round(value * minimapZoom) / minimapZoom;
|
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) {
|
observeCombat(attacker, defender) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
ARENA,
|
ARENA,
|
||||||
FIGHTER,
|
FIGHTER,
|
||||||
COMBAT,
|
COMBAT,
|
||||||
|
PERFORMANCE,
|
||||||
PROJECTILE,
|
PROJECTILE,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,7 +11,7 @@ import {
|
||||||
getMovementSpeedMultiplier,
|
getMovementSpeedMultiplier,
|
||||||
} from "./combatSettings.js";
|
} from "./combatSettings.js";
|
||||||
import {
|
import {
|
||||||
fighterAnimationKey,
|
ensureFighterTeamAnimation,
|
||||||
fighterAttackEffectAnimationKey,
|
fighterAttackEffectAnimationKey,
|
||||||
fighterAttackEffectKey,
|
fighterAttackEffectKey,
|
||||||
fighterProjectileKey,
|
fighterProjectileKey,
|
||||||
|
|
@ -19,18 +20,33 @@ import {
|
||||||
} from "../fighter/fighterAssets.js";
|
} from "../fighter/fighterAssets.js";
|
||||||
import { getFighterStats } from "../fighter/fighterStats.js";
|
import { getFighterStats } from "../fighter/fighterStats.js";
|
||||||
|
|
||||||
export function updateFighter(scene, fighter, time, onWinner) {
|
const TARGET_SCAN_INTERVAL_MS = 180;
|
||||||
const enemy = findNearestEnemy(scene.fighters, fighter);
|
const TARGET_SCAN_JITTER_MS = 90;
|
||||||
|
|
||||||
if (!enemy || fighter.isDead || enemy.isDead || fighter.isFrostStunned || fighter.isLocked) {
|
export function prepareCombatFrame(scene) {
|
||||||
fighter.body.setVelocity(0, 0);
|
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;
|
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);
|
fighter.setFlipX(enemy.x < fighter.x);
|
||||||
|
|
||||||
if (distance > getAttackRange(fighter)) {
|
if (distance > attackRange * attackRange) {
|
||||||
scene.physics.moveToObject(
|
scene.physics.moveToObject(
|
||||||
fighter,
|
fighter,
|
||||||
enemy,
|
enemy,
|
||||||
|
|
@ -397,14 +413,9 @@ function killFighter(defender, winner, onWinner) {
|
||||||
defender.isLocked = true;
|
defender.isLocked = true;
|
||||||
defender.body.setVelocity(0, 0);
|
defender.body.setVelocity(0, 0);
|
||||||
defender.body.enable = false;
|
defender.body.enable = false;
|
||||||
defender.healthBar.width = 0;
|
|
||||||
defender.setAlpha(FIGHTER.DEAD_ALPHA);
|
|
||||||
defender.setDepth(FIGHTER.DEAD_DEPTH);
|
defender.setDepth(FIGHTER.DEAD_DEPTH);
|
||||||
defender.disableInteractive();
|
defender.disableInteractive();
|
||||||
defender.teamMarker?.setVisible(false);
|
defender.releaseHud?.();
|
||||||
defender.nameLabel?.setVisible(false);
|
|
||||||
defender.healthBack?.setVisible(false);
|
|
||||||
defender.healthBar?.setVisible(false);
|
|
||||||
playAnimation(defender, "death");
|
playAnimation(defender, "death");
|
||||||
|
|
||||||
if (winner) {
|
if (winner) {
|
||||||
|
|
@ -419,6 +430,68 @@ function killFighter(defender, winner, onWinner) {
|
||||||
|
|
||||||
maybeSplitFighter(defender);
|
maybeSplitFighter(defender);
|
||||||
onWinner?.(winner);
|
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) {
|
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 nearestEnemy;
|
||||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
fighters.forEach((candidate) => {
|
fighters.forEach((candidate) => {
|
||||||
if (
|
if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) {
|
||||||
candidate === fighter ||
|
|
||||||
candidate.isDead ||
|
|
||||||
candidate.team.id === fighter.team.id
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = Phaser.Math.Distance.Between(
|
const deltaX = fighter.x - candidate.x;
|
||||||
fighter.x,
|
const deltaY = fighter.y - candidate.y;
|
||||||
fighter.y,
|
const distance = deltaX * deltaX + deltaY * deltaY;
|
||||||
candidate.x,
|
|
||||||
candidate.y,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance < nearestDistance) {
|
if (distance < nearestDistance) {
|
||||||
nearestDistance = distance;
|
nearestDistance = distance;
|
||||||
|
|
@ -554,17 +674,142 @@ function findNearestEnemy(fighters, fighter) {
|
||||||
return nearestEnemy;
|
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) {
|
function playIfNeeded(fighter, action) {
|
||||||
const key = fighterAnimationKey(fighter.skin, action);
|
const key = resolveFighterAnimationKey(fighter, action);
|
||||||
|
|
||||||
if (fighter.anims.currentAnim?.key !== key) {
|
if (fighter.anims.currentAnim?.key !== key) {
|
||||||
playAnimation(fighter, action);
|
playResolvedAnimation(fighter, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAnimation(fighter, action, timeScale = 1) {
|
function playAnimation(fighter, action, timeScale = 1) {
|
||||||
|
playResolvedAnimation(fighter, resolveFighterAnimationKey(fighter, action), timeScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playResolvedAnimation(fighter, key, timeScale = 1) {
|
||||||
fighter.anims.timeScale = timeScale;
|
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) {
|
function scaledAttackDelay(duration, fighter) {
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,6 @@ function applyFrostStun(scene, fighter) {
|
||||||
fighter.isFrostStunned = true;
|
fighter.isFrostStunned = true;
|
||||||
fighter.body?.setVelocity(0, 0);
|
fighter.body?.setVelocity(0, 0);
|
||||||
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
|
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, () => {
|
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
||||||
clearFrostStun(fighter);
|
clearFrostStun(fighter);
|
||||||
});
|
});
|
||||||
|
|
@ -402,10 +401,6 @@ function clearFrostStun(fighter) {
|
||||||
if (fighter.active) {
|
if (fighter.active) {
|
||||||
fighter.clearTint();
|
fighter.clearTint();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fighter.teamMarker?.active) {
|
|
||||||
fighter.teamMarker.setTint(fighter.teamColor).setAlpha(0.8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateFrostZone(scene, zone, marker) {
|
function activateFrostZone(scene, zone, marker) {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,29 @@
|
||||||
import {
|
import {
|
||||||
FIGHTER,
|
FIGHTER,
|
||||||
COMBAT,
|
COMBAT,
|
||||||
UI,
|
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
|
|
||||||
const SOURCE_ALPHA_THRESHOLD = 8;
|
|
||||||
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
||||||
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
||||||
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
|
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}`;
|
return `${skin.key}-${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAnimationKey(skin, action) {
|
export function fighterAnimationKey(skin, action, teamColor) {
|
||||||
return `${fighterSheetKey(skin, action)}-anim`;
|
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKey(skin, action) {
|
|
||||||
return `${fighterSheetKey(skin, action)}-outline`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
|
|
||||||
return `${sheetKey}-outline`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAttackEffectKey(skin) {
|
export function fighterAttackEffectKey(skin) {
|
||||||
|
|
@ -82,8 +83,6 @@ export function createFighterAnimations(scene, skins) {
|
||||||
repeat,
|
repeat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createAttackEffectAnimation(scene, skin);
|
createAttackEffectAnimation(scene, skin);
|
||||||
|
|
@ -92,6 +91,47 @@ export function createFighterAnimations(scene, skins) {
|
||||||
createHealEffectAnimation(scene);
|
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) {
|
function preloadCombatAssets(scene, skin) {
|
||||||
const projectile = skin.combat?.projectile;
|
const projectile = skin.combat?.projectile;
|
||||||
const attackEffect = skin.combat?.attackEffect;
|
const attackEffect = skin.combat?.attackEffect;
|
||||||
|
|
@ -149,18 +189,18 @@ function createHealEffectAnimation(scene) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
|
||||||
const key = fighterOutlineSheetKey(skin, action);
|
const key = fighterSheetKey(skin, action, teamColor);
|
||||||
|
|
||||||
if (scene.textures.exists(key)) {
|
if (scene.textures.exists(key)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
||||||
const sourceImage = sourceTexture?.getSourceImage?.();
|
const sourceImage = sourceTexture?.getSourceImage?.();
|
||||||
|
|
||||||
if (!sourceImage) {
|
if (!sourceImage) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
|
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
|
||||||
|
|
@ -172,90 +212,60 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
||||||
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
sourceContext.drawImage(sourceImage, 0, 0);
|
sourceContext.drawImage(sourceImage, 0, 0);
|
||||||
|
|
||||||
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
|
const sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
|
||||||
const outlineCanvas = document.createElement("canvas");
|
const sourceData = sourceImageData.data;
|
||||||
outlineCanvas.width = sheetWidth;
|
const shadowColor = parseHexColor(teamColor);
|
||||||
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);
|
|
||||||
|
|
||||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
const frameLeft = frameIndex * FIGHTER.FRAME_WIDTH;
|
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) {
|
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
|
||||||
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
||||||
|
|
||||||
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
|
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
|
||||||
continue;
|
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);
|
sourceContext.putImageData(sourceImageData, 0, 0);
|
||||||
outlineContext.putImageData(outlineImage, 0, 0);
|
scene.textures.addSpriteSheet(key, sourceCanvas, {
|
||||||
scene.textures.addSpriteSheet(key, outlineCanvas, {
|
|
||||||
frameWidth: FIGHTER.FRAME_WIDTH,
|
frameWidth: FIGHTER.FRAME_WIDTH,
|
||||||
frameHeight: FIGHTER.FRAME_HEIGHT,
|
frameHeight: FIGHTER.FRAME_HEIGHT,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
|
function isTeamShadowPixel(data, index) {
|
||||||
const outerRadius = UI.SELECTED_FIGHTER_OUTLINE_GAP + UI.SELECTED_FIGHTER_OUTLINE_WIDTH;
|
return (
|
||||||
|
data[index + 3] > 0
|
||||||
for (
|
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
|
||||||
let offsetY = -outerRadius;
|
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
|
||||||
offsetY <= outerRadius;
|
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
|
||||||
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 paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
function normalizeTeamColorKey(teamColor) {
|
||||||
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
|
return parseHexColor(teamColor).hex;
|
||||||
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
|
}
|
||||||
continue;
|
|
||||||
}
|
function parseHexColor(teamColor) {
|
||||||
|
const fallback = "ffffff";
|
||||||
const outlineIndex = maskIndex * 4;
|
const hex = typeof teamColor === "string"
|
||||||
|
? teamColor.trim().replace(/^#/, "").toLowerCase()
|
||||||
outlineData[outlineIndex] = 255;
|
: fallback;
|
||||||
outlineData[outlineIndex + 1] = 255;
|
const normalizedHex = /^[0-9a-f]{6}$/.test(hex) ? hex : fallback;
|
||||||
outlineData[outlineIndex + 2] = 255;
|
|
||||||
outlineData[outlineIndex + 3] = outlineAlpha;
|
return {
|
||||||
}
|
blue: parseInt(normalizedHex.slice(4, 6), 16),
|
||||||
|
green: parseInt(normalizedHex.slice(2, 4), 16),
|
||||||
|
hex: normalizedHex,
|
||||||
|
red: parseInt(normalizedHex.slice(0, 2), 16),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
FIGHTER,
|
FIGHTER,
|
||||||
|
PERFORMANCE,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
fighterAnimationKey,
|
ensureFighterTeamAnimation,
|
||||||
fighterOutlineSheetKeyFromSheetKey,
|
ensureFighterTeamAnimations,
|
||||||
fighterSheetKey,
|
fighterSheetKey,
|
||||||
} from "./fighterAssets.js";
|
} from "./fighterAssets.js";
|
||||||
import { getFighterStats } from "./fighterStats.js";
|
import { getFighterStats } from "./fighterStats.js";
|
||||||
|
|
||||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
const NAME_LABEL_BOTTOM_GAP = 14;
|
||||||
|
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
||||||
|
|
||||||
export function createFighter(
|
export function createFighter(
|
||||||
scene,
|
scene,
|
||||||
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
||||||
) {
|
) {
|
||||||
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
|
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||||
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
|
|
||||||
|
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 displayName = name || team.label;
|
||||||
const combatStats = getFighterStats(skin);
|
const combatStats = getFighterStats(skin);
|
||||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
||||||
|
|
@ -44,38 +51,10 @@ export function createFighter(
|
||||||
);
|
);
|
||||||
fighter.input.cursor = "pointer";
|
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.skin = skin;
|
||||||
fighter.combatStats = combatStats;
|
fighter.combatStats = combatStats;
|
||||||
fighter.fighterName = displayName;
|
fighter.fighterName = displayName;
|
||||||
fighter.team = team;
|
fighter.team = team;
|
||||||
fighter.teamColor = teamColor;
|
|
||||||
fighter.teamIndex = teamIndex;
|
fighter.teamIndex = teamIndex;
|
||||||
fighter.baseScaleX = FIGHTER.SCALE;
|
fighter.baseScaleX = FIGHTER.SCALE;
|
||||||
fighter.baseScaleY = FIGHTER.SCALE;
|
fighter.baseScaleY = FIGHTER.SCALE;
|
||||||
|
|
@ -89,84 +68,201 @@ export function createFighter(
|
||||||
fighter.maxHp = resolvedMaxHp;
|
fighter.maxHp = resolvedMaxHp;
|
||||||
fighter.hp = resolvedHp;
|
fighter.hp = resolvedHp;
|
||||||
fighter.nextAttackAt = 0;
|
fighter.nextAttackAt = 0;
|
||||||
|
fighter.nextHudSyncAt = 0;
|
||||||
|
fighter.nextTargetScanAt = 0;
|
||||||
|
fighter.targetEnemy = null;
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
fighter._hudSlot = null;
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
fighter.isDead = 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) => {
|
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||||
if (fighter.isDead) {
|
if (fighter.isDead) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
|
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
syncFighterHud(fighter);
|
|
||||||
|
|
||||||
return 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 isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||||
|
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
||||||
|
|
||||||
fighter.nameLabel.setVisible(isVisible);
|
if (!detailsVisible || !fighter.body) {
|
||||||
fighter.healthBack.setVisible(isVisible);
|
releaseFighterHud(fighter);
|
||||||
fighter.healthBar.setVisible(isVisible);
|
return false;
|
||||||
syncTeamMarker(fighter);
|
|
||||||
|
|
||||||
if (!isVisible || !fighter.body) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
|
||||||
const healthOffset = 44 * scaleRatio;
|
const healthOffset = 44 * scaleRatio;
|
||||||
const hitbox = fighter.body;
|
const hitbox = fighter.body;
|
||||||
const nameX = hitbox.x + hitbox.width / 2;
|
const nameX = hitbox.x + hitbox.width / 2;
|
||||||
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
|
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
|
||||||
|
|
||||||
fighter.nameLabel.setPosition(nameX, nameY);
|
hudSlot.nameLabel.setPosition(nameX, nameY);
|
||||||
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamMarker(fighter) {
|
export function releaseFighterHud(fighter) {
|
||||||
const marker = fighter.teamMarker;
|
const hudSlot = fighter?._hudSlot;
|
||||||
|
|
||||||
if (!marker) {
|
if (!hudSlot) {
|
||||||
|
if (fighter) {
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
setHudSlotVisible(hudSlot, false);
|
||||||
marker.setVisible(isVisible);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
fighter._hudDetailsVisible = visible;
|
||||||
|
setHudSlotVisible(hudSlot, visible);
|
||||||
|
}
|
||||||
|
|
||||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
function setVisibleIfChanged(gameObject, visible) {
|
||||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
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);
|
const hudPool = ensureFighterHudPool(fighter.scene);
|
||||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
|
||||||
marker.setFlipX(fighter.flipX);
|
|
||||||
marker.setDepth(fighter.depth - 0.1);
|
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) {
|
function attachHudCleanup(fighter) {
|
||||||
const originalDestroy = fighter.destroy.bind(fighter);
|
const originalDestroy = fighter.destroy.bind(fighter);
|
||||||
|
|
||||||
fighter.destroy = (...args) => {
|
fighter.destroy = (...args) => {
|
||||||
fighter.teamMarker.destroy();
|
releaseFighterHud(fighter);
|
||||||
fighter.nameLabel.destroy();
|
|
||||||
fighter.healthBack.destroy();
|
|
||||||
fighter.healthBar.destroy();
|
|
||||||
originalDestroy(...args);
|
originalDestroy(...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function createMatchSetup(
|
||||||
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
|
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
|
||||||
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
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 teams = names.map((rawName, index) => {
|
||||||
const match = rawName.match(NAME_MULTIPLIER_REGEX);
|
const match = rawName.match(NAME_MULTIPLIER_REGEX);
|
||||||
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
|
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
|
||||||
|
|
@ -213,18 +213,12 @@ function startingZonesOverlap(left, right) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTeamSize(playerCount, requestedTeamSize) {
|
function resolveTeamSize(requestedTeamSize) {
|
||||||
const teamSize = clamp(
|
return clamp(
|
||||||
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
|
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
|
||||||
1,
|
1,
|
||||||
SPAWN.MAX_TEAM_SIZE,
|
SPAWN.MAX_TEAM_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (playerCount <= teamSize) {
|
|
||||||
return Math.max(1, Math.ceil(playerCount / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnJitter() {
|
function spawnJitter() {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export function createMatchForm() {
|
||||||
const teamSizeInput = getElement("#team-size");
|
const teamSizeInput = getElement("#team-size");
|
||||||
const teamSizeNumberInput = getElement("#team-size-value");
|
const teamSizeNumberInput = getElement("#team-size-value");
|
||||||
|
|
||||||
|
applyTeamSizeInputLimits(teamSizeInput, teamSizeNumberInput);
|
||||||
|
|
||||||
const readMatchConfig = () => ({
|
const readMatchConfig = () => ({
|
||||||
names: nicknameValues(namesInput.value),
|
names: nicknameValues(namesInput.value),
|
||||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||||
|
|
@ -100,6 +102,15 @@ function nicknameValues(value) {
|
||||||
.filter(Boolean);
|
.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) {
|
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
||||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
||||||
|
|
||||||
|
|
|
||||||
13
todo.md
13
todo.md
|
|
@ -272,3 +272,16 @@
|
||||||
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.
|
- `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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue