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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||
|
|
|
|||
|
|
@ -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`을 인스턴스화합니다.
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ Player 10</textarea
|
|||
class="team-size-number"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
value="5"
|
||||
inputmode="numeric"
|
||||
|
|
@ -197,7 +196,6 @@ Player 10</textarea
|
|||
name="teamSize"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value="5"
|
||||
/>
|
||||
<div class="spawn-placement-field">
|
||||
|
|
|
|||
|
|
@ -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 도메인
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
snapMinimapFrameValue(value) {
|
||||
const minimapZoom = this.minimapCamera?.zoom ?? 1;
|
||||
return Math.round(value * minimapZoom) / minimapZoom;
|
||||
return livingCandidates;
|
||||
}
|
||||
|
||||
shouldShowFighterHudDetails() {
|
||||
return this.cameras.main.zoom > CAMERA.MIN_ZOOM || Boolean(this.selectedFighter);
|
||||
}
|
||||
|
||||
observeCombat(attacker, defender) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let offsetX = -outerRadius;
|
||||
offsetX <= outerRadius;
|
||||
offsetX += 1
|
||||
) {
|
||||
const targetX = sourceX + offsetX;
|
||||
|
||||
if (targetX < 0 || targetX >= FIGHTER.FRAME_WIDTH) {
|
||||
continue;
|
||||
function normalizeTeamColorKey(teamColor) {
|
||||
return parseHexColor(teamColor).hex;
|
||||
}
|
||||
|
||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
||||
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;
|
||||
|
||||
outerMask[maskIndex] = 1;
|
||||
|
||||
if (distance <= UI.SELECTED_FIGHTER_OUTLINE_GAP) {
|
||||
gapMask[maskIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
|
||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
||||
fighter._hudDetailsVisible = visible;
|
||||
setHudSlotVisible(hudSlot, visible);
|
||||
}
|
||||
|
||||
marker.setPosition(fighter.x, fighter.y);
|
||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
||||
marker.setFlipX(fighter.flipX);
|
||||
marker.setDepth(fighter.depth - 0.1);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export function createMatchForm() {
|
|||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeNumberInput = getElement("#team-size-value");
|
||||
|
||||
applyTeamSizeInputLimits(teamSizeInput, teamSizeNumberInput);
|
||||
|
||||
const readMatchConfig = () => ({
|
||||
names: nicknameValues(namesInput.value),
|
||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||
|
|
@ -100,6 +102,15 @@ function nicknameValues(value) {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyTeamSizeInputLimits(...inputs) {
|
||||
inputs.forEach((input) => {
|
||||
input.min = "1";
|
||||
input.max = String(SPAWN.MAX_TEAM_SIZE);
|
||||
input.step = "1";
|
||||
input.value = String(SPAWN.DEFAULT_TEAM_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
||||
|
||||
|
|
|
|||
13
todo.md
13
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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue