Refine battle performance and team limits
This commit is contained in:
+18
-4
@@ -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 도메인
|
||||
|
||||
+203
-62
@@ -3,11 +3,12 @@ import {
|
||||
ARENA,
|
||||
CAMERA,
|
||||
COMBAT,
|
||||
PERFORMANCE,
|
||||
SPAWN,
|
||||
UI,
|
||||
} from "../../constants.js";
|
||||
import { drawArena, drawStartingZones } from "./arenaRenderer.js";
|
||||
import { clearCombatObjects, updateFighter } from "../combat/combat.js";
|
||||
import { clearCombatObjects, prepareCombatFrame, updateFighter } from "../combat/combat.js";
|
||||
import {
|
||||
clearWorldEffects,
|
||||
createWorldEffectAnimations,
|
||||
@@ -16,7 +17,11 @@ import {
|
||||
updateWorldEffectModifiers,
|
||||
} from "../combat/worldEffects.js";
|
||||
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
|
||||
import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
|
||||
import {
|
||||
createFighter,
|
||||
releaseUnusedFighterHuds,
|
||||
syncFighterHud,
|
||||
} from "../fighter/fighterFactory.js";
|
||||
import { fighterManifest } from "../fighter/fighterManifest.js";
|
||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
||||
@@ -90,6 +95,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.deathStatsSaved = false;
|
||||
this.finalFocusNextSwitchAt = 0;
|
||||
this.finalFocusTarget = null;
|
||||
this.fighterHudCandidates = [];
|
||||
this.nextFighterHudCandidateRefreshAt = 0;
|
||||
this.spectatorMode = null;
|
||||
this.meteorFocusState = null;
|
||||
this.slowMotionRestoreState = null;
|
||||
@@ -97,6 +104,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.slowMotionTransitionFrame = null;
|
||||
this.startingZoneGraphics = null;
|
||||
this.startingZoneHideTimer = null;
|
||||
this.minimapGraphics = null;
|
||||
this.minimapHudCamera = null;
|
||||
this.worldEffectTimer = null;
|
||||
this.worldEffectZones = new Set();
|
||||
}
|
||||
@@ -116,16 +125,16 @@ export class ArenaScene extends Phaser.Scene {
|
||||
createWorldEffectAnimations(this);
|
||||
|
||||
// 미니맵 카메라 설정
|
||||
this.minimapCamera = this.cameras
|
||||
this.minimapGraphics = this.add.graphics().setDepth(10);
|
||||
this.minimapHudCamera = this.cameras
|
||||
.add(UI.MINIMAP_MARGIN, UI.MINIMAP_MARGIN, UI.MINIMAP_VIEWPORT_SIZE, UI.MINIMAP_VIEWPORT_SIZE)
|
||||
.setZoom(UI.MINIMAP_VIEWPORT_SIZE / ARENA.SIZE)
|
||||
.setName("minimap");
|
||||
this.minimapCamera.setBackgroundColor(0x000000);
|
||||
this.minimapCamera.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
||||
this.minimapViewportFrame = this.add.graphics().setDepth(10);
|
||||
this.cameras.main.ignore(this.minimapViewportFrame);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
||||
.setName("minimap-hud")
|
||||
.setScroll(0, 0)
|
||||
.setZoom(1);
|
||||
this.cameras.main.ignore(this.minimapGraphics);
|
||||
this.syncMinimapHudCameraIgnores();
|
||||
this.events.on("addedtoscene", this.handleGameObjectAddedToScene, this);
|
||||
this.updateMinimap();
|
||||
// 마우스 휠로 줌 조절
|
||||
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
||||
const newZoom = Phaser.Math.Clamp(
|
||||
@@ -185,6 +194,8 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.presentationMode = silent;
|
||||
this.resetMatchDeathStats({ silent });
|
||||
this.observedCombat = [];
|
||||
this.fighterHudCandidates = [];
|
||||
this.nextFighterHudCandidateRefreshAt = 0;
|
||||
this.clearSelectedFighter();
|
||||
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
|
||||
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
||||
@@ -388,14 +399,15 @@ export class ArenaScene extends Phaser.Scene {
|
||||
};
|
||||
}
|
||||
update(time) {
|
||||
this.fighters.forEach(syncFighterHud);
|
||||
this.syncFighterHuds(time);
|
||||
|
||||
if (this.matchPaused) {
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.matchOver) {
|
||||
prepareCombatFrame(this);
|
||||
updateWorldEffectModifiers(this);
|
||||
|
||||
this.fighters.forEach((fighter) => {
|
||||
@@ -408,18 +420,17 @@ update(time) {
|
||||
|
||||
if (this.presentationMode) {
|
||||
this.followPresentationCombat();
|
||||
this.minimapCamera?.setAlpha(0);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.focusSelectedFighter()) {
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.matchOver) {
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -439,7 +450,7 @@ update(time) {
|
||||
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
|
||||
}
|
||||
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
}
|
||||
|
||||
syncSpectatorMode(mode) {
|
||||
@@ -761,13 +772,21 @@ update(time) {
|
||||
this.observedCombat = [];
|
||||
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM));
|
||||
this.centerCameraOnFighter(fighter);
|
||||
syncFighterHud(fighter);
|
||||
syncFighterHud(fighter, {
|
||||
force: true,
|
||||
showDetails: true,
|
||||
time: this.time.now,
|
||||
});
|
||||
}
|
||||
|
||||
clearSelectedFighter() {
|
||||
if (this.selectedFighter) {
|
||||
this.selectedFighter.isSelected = false;
|
||||
syncFighterHud(this.selectedFighter);
|
||||
syncFighterHud(this.selectedFighter, {
|
||||
force: true,
|
||||
showDetails: this.shouldShowFighterHudDetails(),
|
||||
time: this.time.now,
|
||||
});
|
||||
}
|
||||
|
||||
this.selectedFighter = null;
|
||||
@@ -875,8 +894,7 @@ update(time) {
|
||||
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
|
||||
}
|
||||
|
||||
this.minimapCamera?.setAlpha(0);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
}
|
||||
|
||||
followPresentationCombat() {
|
||||
@@ -904,63 +922,186 @@ update(time) {
|
||||
this.observedCombat = [];
|
||||
}
|
||||
|
||||
this.minimapCamera.setAlpha(newZoom > CAMERA.MIN_ZOOM ? UI.MINIMAP_ALPHA : 0);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.updateMinimap();
|
||||
}
|
||||
|
||||
updateMinimapViewportFrame() {
|
||||
if (!this.minimapViewportFrame) {
|
||||
handleGameObjectAddedToScene(gameObject) {
|
||||
this.configureMinimapCameraVisibility(gameObject);
|
||||
}
|
||||
|
||||
syncMinimapHudCameraIgnores() {
|
||||
this.children.getChildren().forEach((gameObject) => {
|
||||
this.configureMinimapCameraVisibility(gameObject);
|
||||
});
|
||||
}
|
||||
|
||||
configureMinimapCameraVisibility(gameObject) {
|
||||
if (!gameObject || !this.minimapHudCamera) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameObject === this.minimapGraphics) {
|
||||
this.cameras.main.ignore(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
this.minimapHudCamera.ignore(gameObject);
|
||||
}
|
||||
|
||||
updateMinimap() {
|
||||
if (!this.minimapGraphics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = this.cameras.main;
|
||||
const graphics = this.minimapGraphics;
|
||||
|
||||
this.minimapViewportFrame.clear();
|
||||
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA.MIN_ZOOM);
|
||||
graphics.clear();
|
||||
graphics.setVisible(!this.presentationMode);
|
||||
|
||||
if (camera.zoom <= CAMERA.MIN_ZOOM) {
|
||||
if (this.presentationMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA.SIZE));
|
||||
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA.SIZE));
|
||||
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
|
||||
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
|
||||
const cameraMidX = scrollX + camera.width / 2;
|
||||
const cameraMidY = scrollY + camera.height / 2;
|
||||
const frameX = Phaser.Math.Clamp(
|
||||
this.snapMinimapFrameValue(cameraMidX - frameWidth / 2),
|
||||
0,
|
||||
ARENA.SIZE - frameWidth,
|
||||
);
|
||||
const frameY = Phaser.Math.Clamp(
|
||||
this.snapMinimapFrameValue(cameraMidY - frameHeight / 2),
|
||||
0,
|
||||
ARENA.SIZE - frameHeight,
|
||||
);
|
||||
const x = 0;
|
||||
const y = 0;
|
||||
const size = UI.MINIMAP_VIEWPORT_SIZE;
|
||||
const stroke = UI.MINIMAP_VIEW_FRAME_STROKE;
|
||||
const dotRadius = PERFORMANCE.MINIMAP_DOT_RADIUS;
|
||||
|
||||
this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight);
|
||||
graphics.setPosition(0, 0);
|
||||
graphics.setScale(1);
|
||||
graphics.fillStyle(0x000000, PERFORMANCE.MINIMAP_BACKGROUND_ALPHA);
|
||||
graphics.fillRect(x, y, size, size);
|
||||
graphics.lineStyle(stroke, 0xffe4a8, PERFORMANCE.MINIMAP_BORDER_ALPHA);
|
||||
graphics.strokeRect(x, y, size, size);
|
||||
this.drawMinimapFighterDots(graphics, x, y, size, dotRadius);
|
||||
this.drawMinimapViewportFrame(graphics, x, y, size, stroke);
|
||||
}
|
||||
|
||||
drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) {
|
||||
const stroke = Math.min(UI.MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
|
||||
const sideHeight = Math.max(0, frameHeight - stroke * 2);
|
||||
drawMinimapFighterDots(graphics, x, y, size, dotRadius) {
|
||||
const livingFighters = this.combatTargetIndex?.livingFighters
|
||||
?? this.fighters.filter(isLivingFighter);
|
||||
|
||||
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight);
|
||||
this.minimapViewportFrame.fillRect(
|
||||
frameX + frameWidth - stroke,
|
||||
frameY + stroke,
|
||||
stroke,
|
||||
sideHeight,
|
||||
);
|
||||
livingFighters.forEach((fighter) => {
|
||||
const dotX = x + Phaser.Math.Clamp(fighter.x / ARENA.SIZE, 0, 1) * size;
|
||||
const dotY = y + Phaser.Math.Clamp(fighter.y / ARENA.SIZE, 0, 1) * size;
|
||||
|
||||
graphics.fillStyle(this.minimapTeamColor(fighter.team), 0.9);
|
||||
graphics.fillCircle(dotX, dotY, dotRadius);
|
||||
});
|
||||
}
|
||||
|
||||
snapMinimapFrameValue(value) {
|
||||
const minimapZoom = this.minimapCamera?.zoom ?? 1;
|
||||
return Math.round(value * minimapZoom) / minimapZoom;
|
||||
drawMinimapViewportFrame(graphics, x, y, size, stroke) {
|
||||
const camera = this.cameras.main;
|
||||
const view = camera.worldView;
|
||||
const frameX = x + Phaser.Math.Clamp(view.x / ARENA.SIZE, 0, 1) * size;
|
||||
const frameY = y + Phaser.Math.Clamp(view.y / ARENA.SIZE, 0, 1) * size;
|
||||
const frameWidth = Math.min(size, Math.max(stroke, (view.width / ARENA.SIZE) * size));
|
||||
const frameHeight = Math.min(size, Math.max(stroke, (view.height / ARENA.SIZE) * size));
|
||||
|
||||
graphics.lineStyle(stroke, 0xffffff, 0.88);
|
||||
graphics.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
}
|
||||
|
||||
minimapTeamColor(team) {
|
||||
if (!team) {
|
||||
return 0xffffff;
|
||||
}
|
||||
|
||||
if (team.minimapColor === undefined) {
|
||||
const color = Number.parseInt(String(team.color).replace(/^#/, ""), 16);
|
||||
team.minimapColor = Number.isFinite(color) ? color : 0xffffff;
|
||||
}
|
||||
|
||||
return team.minimapColor;
|
||||
}
|
||||
|
||||
syncFighterHuds(time) {
|
||||
const candidates = this.resolveFighterHudCandidates(time);
|
||||
|
||||
releaseUnusedFighterHuds(this, candidates);
|
||||
candidates.forEach((fighter) => {
|
||||
syncFighterHud(fighter, {
|
||||
showDetails: true,
|
||||
time,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolveFighterHudCandidates(time) {
|
||||
const selectedFighter = isLivingFighter(this.selectedFighter) ? this.selectedFighter : null;
|
||||
|
||||
if (!this.shouldShowFighterHudDetails()) {
|
||||
this.fighterHudCandidates = selectedFighter ? [selectedFighter] : [];
|
||||
return this.fighterHudCandidates;
|
||||
}
|
||||
|
||||
if (this.cameras.main.zoom <= CAMERA.MIN_ZOOM) {
|
||||
this.fighterHudCandidates = selectedFighter ? [selectedFighter] : [];
|
||||
return this.fighterHudCandidates;
|
||||
}
|
||||
|
||||
const now = Number.isFinite(time) ? time : this.time.now;
|
||||
|
||||
if (now < this.nextFighterHudCandidateRefreshAt && this.fighterHudCandidates.length > 0) {
|
||||
return this.ensureSelectedHudCandidate(this.fighterHudCandidates, selectedFighter);
|
||||
}
|
||||
|
||||
this.nextFighterHudCandidateRefreshAt =
|
||||
now + Math.max(0, PERFORMANCE.FIGHTER_HUD_CANDIDATE_REFRESH_MS);
|
||||
|
||||
const camera = this.cameras.main;
|
||||
const viewPadding = Math.max(0, PERFORMANCE.FIGHTER_HUD_VIEW_PADDING);
|
||||
const viewBounds = new Phaser.Geom.Rectangle(
|
||||
camera.worldView.x - viewPadding,
|
||||
camera.worldView.y - viewPadding,
|
||||
camera.worldView.width + viewPadding * 2,
|
||||
camera.worldView.height + viewPadding * 2,
|
||||
);
|
||||
const cameraCenter = camera.midPoint;
|
||||
const candidates = [];
|
||||
|
||||
this.fighters.forEach((fighter) => {
|
||||
if (!isLivingFighter(fighter) || !viewBounds.contains(fighter.x, fighter.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = fighter.x - cameraCenter.x;
|
||||
const deltaY = fighter.y - cameraCenter.y;
|
||||
|
||||
candidates.push({
|
||||
distance: deltaX * deltaX + deltaY * deltaY,
|
||||
fighter,
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => left.distance - right.distance);
|
||||
|
||||
const limit = Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT));
|
||||
this.fighterHudCandidates = this.ensureSelectedHudCandidate(
|
||||
candidates.slice(0, limit).map((candidate) => candidate.fighter),
|
||||
selectedFighter,
|
||||
);
|
||||
|
||||
return this.fighterHudCandidates;
|
||||
}
|
||||
|
||||
ensureSelectedHudCandidate(candidates, selectedFighter) {
|
||||
const livingCandidates = candidates.filter(isLivingFighter);
|
||||
|
||||
if (selectedFighter && !livingCandidates.includes(selectedFighter)) {
|
||||
return [selectedFighter, ...livingCandidates].slice(
|
||||
0,
|
||||
Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT)),
|
||||
);
|
||||
}
|
||||
|
||||
return livingCandidates;
|
||||
}
|
||||
|
||||
shouldShowFighterHudDetails() {
|
||||
return this.cameras.main.zoom > CAMERA.MIN_ZOOM || Boolean(this.selectedFighter);
|
||||
}
|
||||
|
||||
observeCombat(attacker, defender) {
|
||||
|
||||
+273
-28
@@ -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;
|
||||
}
|
||||
|
||||
for (
|
||||
let offsetX = -outerRadius;
|
||||
offsetX <= outerRadius;
|
||||
offsetX += 1
|
||||
) {
|
||||
const targetX = sourceX + offsetX;
|
||||
|
||||
if (targetX < 0 || targetX >= FIGHTER.FRAME_WIDTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
||||
|
||||
outerMask[maskIndex] = 1;
|
||||
|
||||
if (distance <= UI.SELECTED_FIGHTER_OUTLINE_GAP) {
|
||||
gapMask[maskIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function isTeamShadowPixel(data, index) {
|
||||
return (
|
||||
data[index + 3] > 0
|
||||
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
|
||||
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
|
||||
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
|
||||
);
|
||||
}
|
||||
|
||||
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
||||
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
|
||||
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
|
||||
continue;
|
||||
}
|
||||
function normalizeTeamColorKey(teamColor) {
|
||||
return parseHexColor(teamColor).hex;
|
||||
}
|
||||
|
||||
const outlineIndex = maskIndex * 4;
|
||||
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;
|
||||
|
||||
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);
|
||||
fighter._hudDetailsVisible = visible;
|
||||
setHudSlotVisible(hudSlot, visible);
|
||||
}
|
||||
|
||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
||||
function setVisibleIfChanged(gameObject, visible) {
|
||||
if (gameObject && gameObject.visible !== visible) {
|
||||
gameObject.setVisible(visible);
|
||||
}
|
||||
}
|
||||
|
||||
function setHudSlotVisible(hudSlot, visible) {
|
||||
setVisibleIfChanged(hudSlot.nameLabel, visible);
|
||||
setVisibleIfChanged(hudSlot.healthBack, visible);
|
||||
setVisibleIfChanged(hudSlot.healthBar, visible);
|
||||
}
|
||||
|
||||
function acquireFighterHudSlot(fighter) {
|
||||
if (fighter._hudSlot) {
|
||||
return fighter._hudSlot;
|
||||
}
|
||||
|
||||
marker.setPosition(fighter.x, fighter.y);
|
||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
||||
marker.setFlipX(fighter.flipX);
|
||||
marker.setDepth(fighter.depth - 0.1);
|
||||
const hudPool = ensureFighterHudPool(fighter.scene);
|
||||
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
|
||||
|
||||
if (!hudSlot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hudSlot.fighter = fighter;
|
||||
fighter._hudSlot = hudSlot;
|
||||
configureHudSlot(hudSlot, fighter);
|
||||
return hudSlot;
|
||||
}
|
||||
|
||||
function ensureFighterHudPool(scene) {
|
||||
if (scene.fighterHudPool) {
|
||||
return scene.fighterHudPool;
|
||||
}
|
||||
|
||||
const poolSize = Math.max(0, Math.round(PERFORMANCE.FIGHTER_HUD_POOL_SIZE));
|
||||
scene.fighterHudPool = Array.from({ length: poolSize }, () => createHudSlot(scene));
|
||||
return scene.fighterHudPool;
|
||||
}
|
||||
|
||||
function createHudSlot(scene) {
|
||||
const nameLabel = scene.add
|
||||
.text(0, 0, "", {
|
||||
color: "#fff2c2",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "18px",
|
||||
fontStyle: "700",
|
||||
stroke: "#17180e",
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5, 0)
|
||||
.setDepth(4)
|
||||
.setVisible(false);
|
||||
const healthBack = scene.add
|
||||
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
|
||||
.setDepth(4)
|
||||
.setVisible(false);
|
||||
const healthBar = scene.add
|
||||
.rectangle(0, 0, 68, 4, 0xd95f3f, 1)
|
||||
.setOrigin(0, 0.5)
|
||||
.setDepth(5)
|
||||
.setVisible(false);
|
||||
|
||||
return {
|
||||
fighter: null,
|
||||
healthBack,
|
||||
healthBar,
|
||||
nameLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function configureHudSlot(hudSlot, fighter) {
|
||||
hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? "");
|
||||
hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4);
|
||||
hudSlot.nameLabel.setDepth(4);
|
||||
hudSlot.healthBack.setDepth(4);
|
||||
hudSlot.healthBar.setDepth(5);
|
||||
}
|
||||
|
||||
function attachHudCleanup(fighter) {
|
||||
const originalDestroy = fighter.destroy.bind(fighter);
|
||||
|
||||
fighter.destroy = (...args) => {
|
||||
fighter.teamMarker.destroy();
|
||||
fighter.nameLabel.destroy();
|
||||
fighter.healthBack.destroy();
|
||||
fighter.healthBar.destroy();
|
||||
releaseFighterHud(fighter);
|
||||
originalDestroy(...args);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user