Refactor ArenaScene.js: modularize match runtime, spectator camera, and UI components
This commit is contained in:
parent
25137cf26e
commit
bb08d5cee1
|
|
@ -31,6 +31,41 @@ import { fighterManifest } from "./fighterManifest.js";
|
||||||
import { pickFighters } from "./fighterSelection.js";
|
import { pickFighters } from "./fighterSelection.js";
|
||||||
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
|
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
|
||||||
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
|
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
|
||||||
|
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "./arenaMatchRuntime.js";
|
||||||
|
import {
|
||||||
|
createDeathCounts,
|
||||||
|
normalizeDeathCounts,
|
||||||
|
addDeathCounts,
|
||||||
|
normalizeSpecies,
|
||||||
|
createDeathNoticeMessage,
|
||||||
|
} from "../ui/battleDeathNotice.js";
|
||||||
|
import {
|
||||||
|
getSpectatorState,
|
||||||
|
averageFighterPosition,
|
||||||
|
fighterCameraPoint,
|
||||||
|
findClosestOpponentPair,
|
||||||
|
isLivingOpponentPair,
|
||||||
|
isLivingFighter,
|
||||||
|
} from "./arenaSpectatorCamera.js";
|
||||||
|
import {
|
||||||
|
easeOutCubic,
|
||||||
|
easeInOutCubic,
|
||||||
|
arcadePhysicsTimeScale,
|
||||||
|
} from "./arenaFinalCombatEffects.js";
|
||||||
|
import {
|
||||||
|
createVictoryCelebration,
|
||||||
|
primeVictoryFanfareAudio,
|
||||||
|
removeVictoryCelebration,
|
||||||
|
} from "../ui/victoryCelebration.js";
|
||||||
|
import { updateScoreboard } from "../ui/arenaScoreboard.js";
|
||||||
|
import { appendKillLog, resetKillLog } from "../ui/arenaKillLog.js";
|
||||||
|
import {
|
||||||
|
BATTLE_NOTICE_DELAY_MS,
|
||||||
|
BATTLE_NOTICE_INTERVAL_MS,
|
||||||
|
BATTLE_NOTICE_VISIBLE_MS,
|
||||||
|
clearBattleNotice,
|
||||||
|
showBattleDeathNotice,
|
||||||
|
} from "../ui/battleDeathNotice.js";
|
||||||
|
|
||||||
export class ArenaScene extends Phaser.Scene {
|
export class ArenaScene extends Phaser.Scene {
|
||||||
constructor({ getInitialMatchConfig, setStatus }) {
|
constructor({ getInitialMatchConfig, setStatus }) {
|
||||||
|
|
@ -206,14 +241,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetKillLog() {
|
resetKillLog() {
|
||||||
const { logNode, listNode } = this.getKillLogNodes();
|
resetKillLog(this.getKillLogNodes());
|
||||||
|
|
||||||
if (listNode) {
|
|
||||||
listNode.replaceChildren();
|
|
||||||
}
|
|
||||||
|
|
||||||
logNode?.classList.remove("has-entries");
|
|
||||||
logNode?.setAttribute("aria-hidden", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetMatchDeathStats({ silent = false } = {}) {
|
resetMatchDeathStats({ silent = false } = {}) {
|
||||||
|
|
@ -262,10 +290,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.battleNoticeTimer = null;
|
this.battleNoticeTimer = null;
|
||||||
this.battleNoticeHideTimer = null;
|
this.battleNoticeHideTimer = null;
|
||||||
|
|
||||||
const noticeNode = this.getBattleNoticeNode();
|
clearBattleNotice(this.getBattleNoticeNode());
|
||||||
|
|
||||||
noticeNode?.classList.remove("is-visible");
|
|
||||||
noticeNode?.setAttribute("aria-hidden", "true");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordDeath(fighter) {
|
recordDeath(fighter) {
|
||||||
|
|
@ -284,19 +309,17 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
noticeNode.textContent = createDeathNoticeMessage(
|
const message = createDeathNoticeMessage(
|
||||||
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
|
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
|
||||||
this.matchId + this.battleNoticeSequence,
|
this.matchId + this.battleNoticeSequence,
|
||||||
);
|
);
|
||||||
this.battleNoticeSequence += 1;
|
this.battleNoticeSequence += 1;
|
||||||
noticeNode.classList.add("is-visible");
|
showBattleDeathNotice(noticeNode, message);
|
||||||
noticeNode.setAttribute("aria-hidden", "false");
|
|
||||||
|
|
||||||
this.battleNoticeHideTimer?.remove(false);
|
this.battleNoticeHideTimer?.remove(false);
|
||||||
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
|
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
|
||||||
this.battleNoticeHideTimer = null;
|
this.battleNoticeHideTimer = null;
|
||||||
noticeNode.classList.remove("is-visible");
|
clearBattleNotice(noticeNode);
|
||||||
noticeNode.setAttribute("aria-hidden", "true");
|
|
||||||
|
|
||||||
if (!this.matchOver && !this.presentationMode) {
|
if (!this.matchOver && !this.presentationMode) {
|
||||||
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
|
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
|
||||||
|
|
@ -333,48 +356,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recordDeath(defender);
|
this.recordDeath(defender);
|
||||||
|
appendKillLog(this.getKillLogNodes(), winner, defender);
|
||||||
const { logNode, listNode } = this.getKillLogNodes();
|
|
||||||
|
|
||||||
if (!logNode || !listNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = document.createElement("li");
|
|
||||||
const killer = killLogFighterParts(winner);
|
|
||||||
const victim = killLogFighterParts(defender);
|
|
||||||
const action = document.createElement("span");
|
|
||||||
const weapon = document.createElement("span");
|
|
||||||
const actionText = document.createElement("span");
|
|
||||||
|
|
||||||
item.className = "kill-log-item";
|
|
||||||
item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
|
|
||||||
item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
|
|
||||||
item.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
`${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
action.className = "kill-log-action";
|
|
||||||
weapon.className = "kill-log-weapon";
|
|
||||||
weapon.setAttribute("aria-hidden", "true");
|
|
||||||
actionText.className = "kill-log-action-text";
|
|
||||||
actionText.textContent = "처치";
|
|
||||||
action.append(weapon, actionText);
|
|
||||||
|
|
||||||
item.append(
|
|
||||||
createKillLogFighterNode(killer, "killer"),
|
|
||||||
action,
|
|
||||||
createKillLogFighterNode(victim, "victim"),
|
|
||||||
);
|
|
||||||
listNode.append(item);
|
|
||||||
|
|
||||||
while (listNode.children.length > KILL_LOG_LIMIT) {
|
|
||||||
listNode.firstElementChild?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
logNode.classList.add("has-entries");
|
|
||||||
logNode.setAttribute("aria-hidden", "false");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getKillLogNodes() {
|
getKillLogNodes() {
|
||||||
|
|
@ -911,50 +893,16 @@ update(time) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScoreboard() {
|
updateScoreboard() {
|
||||||
const scoreLeft = document.getElementById("score-left");
|
updateScoreboard(
|
||||||
const scoreRight = document.getElementById("score-right");
|
document.getElementById("score-left"),
|
||||||
|
document.getElementById("score-right"),
|
||||||
if (!scoreLeft || !scoreRight) return;
|
this.teams,
|
||||||
|
this.fighters,
|
||||||
scoreLeft.innerHTML = "";
|
{
|
||||||
scoreRight.innerHTML = "";
|
selectedFighterTeamId: this.selectedFighter?.team.id,
|
||||||
|
onTeamClick: (teamId) => this.selectRandomTeamFighter(teamId),
|
||||||
this.teams.forEach((team) => {
|
},
|
||||||
const aliveCount = this.fighters.filter(
|
);
|
||||||
(f) => f.team.id === team.id && !f.isDead
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const teamEl = document.createElement("button");
|
|
||||||
teamEl.className = "team-score";
|
|
||||||
teamEl.type = "button";
|
|
||||||
teamEl.disabled = aliveCount === 0;
|
|
||||||
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
|
||||||
teamEl.style.setProperty("--team-color", team.color);
|
|
||||||
teamEl.style.backgroundColor = `${team.color}33`;
|
|
||||||
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
|
||||||
|
|
||||||
if (this.selectedFighter?.team.id === team.id) {
|
|
||||||
teamEl.classList.add("is-focused");
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelEl = document.createElement("span");
|
|
||||||
labelEl.className = "team-score-name";
|
|
||||||
labelEl.textContent = team.label;
|
|
||||||
|
|
||||||
const ruleEl = document.createElement("span");
|
|
||||||
ruleEl.className = "team-score-rule";
|
|
||||||
|
|
||||||
const countEl = document.createElement("span");
|
|
||||||
countEl.className = "team-score-count";
|
|
||||||
countEl.textContent = `${aliveCount}명`;
|
|
||||||
|
|
||||||
teamEl.addEventListener("click", () => {
|
|
||||||
this.selectRandomTeamFighter(team.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
teamEl.append(labelEl, ruleEl, countEl);
|
|
||||||
scoreLeft.appendChild(teamEl);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finishMatch() {
|
finishMatch() {
|
||||||
|
|
@ -996,456 +944,3 @@ update(time) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const KILL_LOG_LIMIT = 8;
|
|
||||||
const BATTLE_NOTICE_DELAY_MS = 5000;
|
|
||||||
const BATTLE_NOTICE_VISIBLE_MS = 2000;
|
|
||||||
const BATTLE_NOTICE_INTERVAL_MS = 10000;
|
|
||||||
const SPAWN_CLUSTER_MARGIN = 48;
|
|
||||||
const SPAWN_CLUSTER_STEP = 28;
|
|
||||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
|
||||||
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
|
||||||
const SPECIES_LABELS = {
|
|
||||||
bear: "곰",
|
|
||||||
human: "인간",
|
|
||||||
orc: "오크",
|
|
||||||
skeleton: "해골",
|
|
||||||
slime: "슬라임",
|
|
||||||
wolf: "늑대",
|
|
||||||
};
|
|
||||||
const SPECIES_SUBJECT_PARTICLES = {
|
|
||||||
bear: "이",
|
|
||||||
human: "이",
|
|
||||||
orc: "가",
|
|
||||||
skeleton: "이",
|
|
||||||
slime: "이",
|
|
||||||
wolf: "가",
|
|
||||||
};
|
|
||||||
const DEATH_NOTICE_TEMPLATES = [
|
|
||||||
"오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
|
|
||||||
"{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
|
|
||||||
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
|
|
||||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
|
||||||
];
|
|
||||||
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
|
||||||
const VICTORY_CONFETTI_COUNT = 40;
|
|
||||||
const VICTORY_FANFARE_NOTES = [
|
|
||||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
|
||||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
|
||||||
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
|
|
||||||
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
|
|
||||||
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
|
|
||||||
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
|
|
||||||
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
|
|
||||||
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
|
|
||||||
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let victoryAudioContext = null;
|
|
||||||
|
|
||||||
function removeVictoryCelebration() {
|
|
||||||
document.querySelector(".victory-celebration")?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createVictoryCelebration(message) {
|
|
||||||
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
|
|
||||||
|
|
||||||
if (!celebrationHost) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVictory = message.includes("승리");
|
|
||||||
const celebration = document.createElement("div");
|
|
||||||
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
|
|
||||||
celebration.setAttribute("aria-hidden", "true");
|
|
||||||
|
|
||||||
const rays = document.createElement("span");
|
|
||||||
rays.className = "victory-rays";
|
|
||||||
|
|
||||||
const confetti = document.createElement("span");
|
|
||||||
confetti.className = "victory-confetti";
|
|
||||||
|
|
||||||
if (isVictory) {
|
|
||||||
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
|
|
||||||
confetti.appendChild(createVictoryConfettiPiece(index));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner = document.createElement("div");
|
|
||||||
banner.className = "victory-banner";
|
|
||||||
|
|
||||||
const messageNode = document.createElement("span");
|
|
||||||
messageNode.className = "victory-banner-message";
|
|
||||||
messageNode.textContent = message;
|
|
||||||
|
|
||||||
banner.appendChild(messageNode);
|
|
||||||
celebration.append(rays, confetti, banner);
|
|
||||||
celebrationHost.appendChild(celebration);
|
|
||||||
|
|
||||||
if (isVictory) {
|
|
||||||
playVictoryFanfare();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createVictoryConfettiPiece(index) {
|
|
||||||
const piece = document.createElement("i");
|
|
||||||
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
|
|
||||||
const distance = 170 + (index % 8) * 26;
|
|
||||||
const x = Math.round(Math.cos(angle) * distance);
|
|
||||||
const y = Math.round(Math.sin(angle) * distance * 0.78);
|
|
||||||
|
|
||||||
piece.className = "victory-confetti-piece";
|
|
||||||
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
|
|
||||||
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
|
|
||||||
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
|
|
||||||
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
|
|
||||||
piece.style.setProperty("--confetti-x", `${x}px`);
|
|
||||||
piece.style.setProperty("--confetti-y", `${y}px`);
|
|
||||||
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
|
|
||||||
|
|
||||||
return piece;
|
|
||||||
}
|
|
||||||
|
|
||||||
function primeVictoryFanfareAudio() {
|
|
||||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
|
||||||
|
|
||||||
if (!AudioContextClass) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!victoryAudioContext) {
|
|
||||||
victoryAudioContext = new AudioContextClass();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (victoryAudioContext.state === "suspended") {
|
|
||||||
victoryAudioContext.resume().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return victoryAudioContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
function playVictoryFanfare() {
|
|
||||||
const audioContext = primeVictoryFanfareAudio();
|
|
||||||
|
|
||||||
if (!audioContext || audioContext.state !== "running") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAt = audioContext.currentTime + 0.03;
|
|
||||||
|
|
||||||
VICTORY_FANFARE_NOTES.forEach((note) => {
|
|
||||||
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
|
|
||||||
const oscillator = audioContext.createOscillator();
|
|
||||||
const gain = audioContext.createGain();
|
|
||||||
const releaseAt = startAt + duration;
|
|
||||||
|
|
||||||
oscillator.type = "triangle";
|
|
||||||
oscillator.frequency.setValueAtTime(frequency, startAt);
|
|
||||||
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
|
|
||||||
|
|
||||||
gain.gain.setValueAtTime(0.0001, startAt);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
|
|
||||||
|
|
||||||
oscillator.connect(gain);
|
|
||||||
gain.connect(audioContext.destination);
|
|
||||||
oscillator.start(startAt);
|
|
||||||
oscillator.stop(releaseAt + 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeOutCubic(progress) {
|
|
||||||
return 1 - (1 - progress) ** 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeInOutCubic(progress) {
|
|
||||||
if (progress < 0.5) {
|
|
||||||
return 4 * progress ** 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1 - ((-2 * progress + 2) ** 3) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arcadePhysicsTimeScale(sceneTimeScale) {
|
|
||||||
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeathCounts() {
|
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
|
||||||
counts[species] = 0;
|
|
||||||
return counts;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDeathCounts(value = {}) {
|
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
|
||||||
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
|
|
||||||
return counts;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDeathCounts(baseCounts, matchCounts) {
|
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
|
||||||
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
|
||||||
return counts;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSpecies(value) {
|
|
||||||
return SPECIES_KEYS.includes(value) ? value : "human";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
|
||||||
const topSpecies = SPECIES_KEYS
|
|
||||||
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
|
|
||||||
.sort((left, right) => right.count - left.count)[0];
|
|
||||||
|
|
||||||
if (!topSpecies || topSpecies.count === 0) {
|
|
||||||
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = DEATH_NOTICE_TEMPLATES[
|
|
||||||
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
|
||||||
];
|
|
||||||
|
|
||||||
return template
|
|
||||||
.replace("{species}", SPECIES_LABELS[topSpecies.species])
|
|
||||||
.replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
|
|
||||||
.replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createKillLogFighterNode(fighterParts, role) {
|
|
||||||
const container = document.createElement("span");
|
|
||||||
const avatar = document.createElement("span");
|
|
||||||
const copy = document.createElement("span");
|
|
||||||
const team = document.createElement("span");
|
|
||||||
const member = document.createElement("span");
|
|
||||||
|
|
||||||
container.className = `kill-log-fighter ${role}`;
|
|
||||||
avatar.className = "kill-log-avatar";
|
|
||||||
if (fighterParts.avatarUrl) {
|
|
||||||
avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
|
|
||||||
}
|
|
||||||
avatar.setAttribute("aria-hidden", "true");
|
|
||||||
copy.className = "kill-log-copy";
|
|
||||||
team.className = "kill-log-team";
|
|
||||||
team.textContent = fighterParts.teamLabel;
|
|
||||||
member.className = "kill-log-member";
|
|
||||||
member.textContent = fighterParts.memberLabel;
|
|
||||||
|
|
||||||
copy.append(team, member);
|
|
||||||
container.append(avatar, copy);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
function killLogFighterParts(fighter) {
|
|
||||||
return {
|
|
||||||
teamLabel: fighter?.team?.label ?? "Unknown",
|
|
||||||
memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
|
|
||||||
avatarUrl: fighterSkinIdleUrl(fighter?.skin),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fighterSkinIdleUrl(skin) {
|
|
||||||
const idleFile = skin?.animations?.idle?.file;
|
|
||||||
|
|
||||||
if (!skin?.assetRoot || !idleFile) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${skin.assetRoot}/${idleFile}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
|
||||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
|
||||||
const skin = skins[index];
|
|
||||||
const spawnMultiplier = expandSpawnMultipliers
|
|
||||||
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
|
|
||||||
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...fighterSetup,
|
|
||||||
skin,
|
|
||||||
x: position.x,
|
|
||||||
y: position.y,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clusterSpawnPosition(origin, index, count) {
|
|
||||||
if (count <= 1 || index === 0) {
|
|
||||||
return {
|
|
||||||
x: origin.x,
|
|
||||||
y: origin.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ring = Math.ceil(index / 6);
|
|
||||||
const radius = SPAWN_CLUSTER_STEP * ring;
|
|
||||||
const angle = index * GOLDEN_ANGLE;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
|
|
||||||
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampInsideArena(value) {
|
|
||||||
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncTeamSizes(teams, fighterPlans) {
|
|
||||||
teams.forEach((team) => {
|
|
||||||
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findClosestOpponentPair(fighters) {
|
|
||||||
let closestPair;
|
|
||||||
let closestDistance = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
fighters.forEach((fighter, index) => {
|
|
||||||
if (!isLivingFighter(fighter)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
|
|
||||||
const candidate = fighters[candidateIndex];
|
|
||||||
|
|
||||||
if (!isLivingOpponentPair([fighter, candidate])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
|
||||||
|
|
||||||
if (distance < closestDistance) {
|
|
||||||
closestDistance = distance;
|
|
||||||
closestPair = [fighter, candidate];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return closestPair;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpectatorState(livingFighters) {
|
|
||||||
const livingFighterCount = livingFighters.length;
|
|
||||||
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
|
||||||
|
|
||||||
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
|
||||||
return {
|
|
||||||
isFinal: true,
|
|
||||||
mode: "final-random",
|
|
||||||
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
|
|
||||||
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
isFinal: true,
|
|
||||||
mode: "final-underdog",
|
|
||||||
teamId: getUnderdogTeamId(teamSummaries),
|
|
||||||
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
|
|
||||||
return {
|
|
||||||
isFinal: false,
|
|
||||||
mode: "late",
|
|
||||||
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLivingTeamSummaries(livingFighters) {
|
|
||||||
const summaries = new Map();
|
|
||||||
|
|
||||||
livingFighters.forEach((fighter) => {
|
|
||||||
const teamId = fighter.team.id;
|
|
||||||
const summary = summaries.get(teamId) ?? {
|
|
||||||
count: 0,
|
|
||||||
teamId,
|
|
||||||
};
|
|
||||||
|
|
||||||
summary.count += 1;
|
|
||||||
summaries.set(teamId, summary);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(summaries.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUnderdogTeamId(teamSummaries) {
|
|
||||||
const sortedTeams = [...teamSummaries].sort((left, right) => left.count - right.count);
|
|
||||||
|
|
||||||
if (sortedTeams.length < 2 || sortedTeams[0].count === sortedTeams[1].count) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortedTeams[0].teamId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageFighterPosition(fighters) {
|
|
||||||
if (fighters.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = fighters.reduce(
|
|
||||||
(position, fighter) => {
|
|
||||||
const point = fighterCameraPoint(fighter);
|
|
||||||
position.x += point.x;
|
|
||||||
position.y += point.y;
|
|
||||||
return position;
|
|
||||||
},
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: total.x / fighters.length,
|
|
||||||
y: total.y / fighters.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fighterCameraPoint(fighter) {
|
|
||||||
const target = fighter?.body?.center ?? fighter;
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: target.x,
|
|
||||||
y: target.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLivingOpponentPair(pair) {
|
|
||||||
if (pair.length !== 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fighterA, fighterB] = pair;
|
|
||||||
|
|
||||||
return (
|
|
||||||
isLivingFighter(fighterA) &&
|
|
||||||
isLivingFighter(fighterB) &&
|
|
||||||
fighterA.team.id !== fighterB.team.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLivingFighter(fighter) {
|
|
||||||
return fighter?.active && !fighter.isDead;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function easeOutCubic(progress) {
|
||||||
|
return 1 - (1 - progress) ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function easeInOutCubic(progress) {
|
||||||
|
if (progress < 0.5) {
|
||||||
|
return 4 * progress ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1 - ((-2 * progress + 2) ** 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arcadePhysicsTimeScale(sceneTimeScale) {
|
||||||
|
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import { ARENA_SIZE } from "../constants.js";
|
||||||
|
|
||||||
|
const SPAWN_CLUSTER_MARGIN = 48;
|
||||||
|
const SPAWN_CLUSTER_STEP = 28;
|
||||||
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
|
||||||
|
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
||||||
|
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||||
|
const skin = skins[index];
|
||||||
|
const spawnMultiplier = expandSpawnMultipliers
|
||||||
|
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
|
||||||
|
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fighterSetup,
|
||||||
|
skin,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clusterSpawnPosition(origin, index, count) {
|
||||||
|
if (count <= 1 || index === 0) {
|
||||||
|
return {
|
||||||
|
x: origin.x,
|
||||||
|
y: origin.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = Math.ceil(index / 6);
|
||||||
|
const radius = SPAWN_CLUSTER_STEP * ring;
|
||||||
|
const angle = index * GOLDEN_ANGLE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
|
||||||
|
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampInsideArena(value) {
|
||||||
|
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncTeamSizes(teams, fighterPlans) {
|
||||||
|
teams.forEach((team) => {
|
||||||
|
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import {
|
||||||
|
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
||||||
|
SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
|
SPECTATOR_FINAL_TEAM_COUNT,
|
||||||
|
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,
|
||||||
|
SPECTATOR_LATE_FIGHTER_THRESHOLD,
|
||||||
|
SPECTATOR_LATE_FIGHT_ZOOM,
|
||||||
|
} from "../constants.js";
|
||||||
|
|
||||||
|
export function getSpectatorState(livingFighters) {
|
||||||
|
const livingFighterCount = livingFighters.length;
|
||||||
|
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
||||||
|
|
||||||
|
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
isFinal: true,
|
||||||
|
mode: "final-random",
|
||||||
|
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
|
||||||
|
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isFinal: true,
|
||||||
|
mode: "final-underdog",
|
||||||
|
teamId: getUnderdogTeamId(teamSummaries),
|
||||||
|
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
isFinal: false,
|
||||||
|
mode: "late",
|
||||||
|
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLivingTeamSummaries(livingFighters) {
|
||||||
|
const summaries = new Map();
|
||||||
|
|
||||||
|
livingFighters.forEach((fighter) => {
|
||||||
|
const teamId = fighter.team.id;
|
||||||
|
const summary = summaries.get(teamId) ?? {
|
||||||
|
count: 0,
|
||||||
|
teamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
summary.count += 1;
|
||||||
|
summaries.set(teamId, summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(summaries.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnderdogTeamId(teamSummaries) {
|
||||||
|
const sortedTeams = [...teamSummaries].sort((left, right) => left.count - right.count);
|
||||||
|
|
||||||
|
if (sortedTeams.length < 2 || sortedTeams[0].count === sortedTeams[1].count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedTeams[0].teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function averageFighterPosition(fighters) {
|
||||||
|
if (fighters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = fighters.reduce(
|
||||||
|
(position, fighter) => {
|
||||||
|
const point = fighterCameraPoint(fighter);
|
||||||
|
position.x += point.x;
|
||||||
|
position.y += point.y;
|
||||||
|
return position;
|
||||||
|
},
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: total.x / fighters.length,
|
||||||
|
y: total.y / fighters.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fighterCameraPoint(fighter) {
|
||||||
|
const target = fighter?.body?.center ?? fighter;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findClosestOpponentPair(fighters) {
|
||||||
|
let closestPair;
|
||||||
|
let closestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
fighters.forEach((fighter, index) => {
|
||||||
|
if (!isLivingFighter(fighter)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
|
||||||
|
const candidate = fighters[candidateIndex];
|
||||||
|
|
||||||
|
if (!isLivingOpponentPair([fighter, candidate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
||||||
|
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestPair = [fighter, candidate];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return closestPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLivingOpponentPair(pair) {
|
||||||
|
if (pair.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fighterA, fighterB] = pair;
|
||||||
|
|
||||||
|
return (
|
||||||
|
isLivingFighter(fighterA) &&
|
||||||
|
isLivingFighter(fighterB) &&
|
||||||
|
fighterA.team.id !== fighterB.team.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLivingFighter(fighter) {
|
||||||
|
return fighter?.active && !fighter.isDead;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
const KILL_LOG_LIMIT = 8;
|
||||||
|
|
||||||
|
export function resetKillLog(nodes) {
|
||||||
|
const { logNode, listNode } = nodes;
|
||||||
|
|
||||||
|
if (listNode) {
|
||||||
|
listNode.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
logNode?.classList.remove("has-entries");
|
||||||
|
logNode?.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendKillLog(nodes, winner, defender) {
|
||||||
|
const { logNode, listNode } = nodes;
|
||||||
|
|
||||||
|
if (!logNode || !listNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const killer = killLogFighterParts(winner);
|
||||||
|
const victim = killLogFighterParts(defender);
|
||||||
|
const action = document.createElement("span");
|
||||||
|
const weapon = document.createElement("span");
|
||||||
|
const actionText = document.createElement("span");
|
||||||
|
|
||||||
|
item.className = "kill-log-item";
|
||||||
|
item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
|
||||||
|
item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
|
||||||
|
item.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
`${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
action.className = "kill-log-action";
|
||||||
|
weapon.className = "kill-log-weapon";
|
||||||
|
weapon.setAttribute("aria-hidden", "true");
|
||||||
|
actionText.className = "kill-log-action-text";
|
||||||
|
actionText.textContent = "처치";
|
||||||
|
action.append(weapon, actionText);
|
||||||
|
|
||||||
|
item.append(
|
||||||
|
createKillLogFighterNode(killer, "killer"),
|
||||||
|
action,
|
||||||
|
createKillLogFighterNode(victim, "victim"),
|
||||||
|
);
|
||||||
|
listNode.append(item);
|
||||||
|
|
||||||
|
while (listNode.children.length > KILL_LOG_LIMIT) {
|
||||||
|
listNode.firstElementChild?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
logNode.classList.add("has-entries");
|
||||||
|
logNode.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKillLogFighterNode(fighterParts, role) {
|
||||||
|
const container = document.createElement("span");
|
||||||
|
const avatar = document.createElement("span");
|
||||||
|
const copy = document.createElement("span");
|
||||||
|
const team = document.createElement("span");
|
||||||
|
const member = document.createElement("span");
|
||||||
|
|
||||||
|
container.className = `kill-log-fighter ${role}`;
|
||||||
|
avatar.className = "kill-log-avatar";
|
||||||
|
if (fighterParts.avatarUrl) {
|
||||||
|
avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
|
||||||
|
}
|
||||||
|
avatar.setAttribute("aria-hidden", "true");
|
||||||
|
copy.className = "kill-log-copy";
|
||||||
|
team.className = "kill-log-team";
|
||||||
|
team.textContent = fighterParts.teamLabel;
|
||||||
|
member.className = "kill-log-member";
|
||||||
|
member.textContent = fighterParts.memberLabel;
|
||||||
|
|
||||||
|
copy.append(team, member);
|
||||||
|
container.append(avatar, copy);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killLogFighterParts(fighter) {
|
||||||
|
return {
|
||||||
|
teamLabel: fighter?.team?.label ?? "Unknown",
|
||||||
|
memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
|
||||||
|
avatarUrl: fighterSkinIdleUrl(fighter?.skin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fighterSkinIdleUrl(skin) {
|
||||||
|
const idleFile = skin?.animations?.idle?.file;
|
||||||
|
|
||||||
|
if (!skin?.assetRoot || !idleFile) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${skin.assetRoot}/${idleFile}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
export function updateScoreboard(
|
||||||
|
containerLeft,
|
||||||
|
containerRight,
|
||||||
|
teams,
|
||||||
|
fighters,
|
||||||
|
{ selectedFighterTeamId = null, onTeamClick = () => {} } = {},
|
||||||
|
) {
|
||||||
|
if (!containerLeft || !containerRight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerLeft.innerHTML = "";
|
||||||
|
containerRight.innerHTML = "";
|
||||||
|
|
||||||
|
teams.forEach((team) => {
|
||||||
|
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
|
||||||
|
|
||||||
|
const teamEl = document.createElement("button");
|
||||||
|
teamEl.className = "team-score";
|
||||||
|
teamEl.type = "button";
|
||||||
|
teamEl.disabled = aliveCount === 0;
|
||||||
|
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||||
|
teamEl.style.setProperty("--team-color", team.color);
|
||||||
|
teamEl.style.backgroundColor = `${team.color}33`;
|
||||||
|
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
||||||
|
|
||||||
|
if (selectedFighterTeamId === team.id) {
|
||||||
|
teamEl.classList.add("is-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelEl = document.createElement("span");
|
||||||
|
labelEl.className = "team-score-name";
|
||||||
|
labelEl.textContent = team.label;
|
||||||
|
|
||||||
|
const ruleEl = document.createElement("span");
|
||||||
|
ruleEl.className = "team-score-rule";
|
||||||
|
|
||||||
|
const countEl = document.createElement("span");
|
||||||
|
countEl.className = "team-score-count";
|
||||||
|
countEl.textContent = `${aliveCount}명`;
|
||||||
|
|
||||||
|
teamEl.addEventListener("click", () => {
|
||||||
|
onTeamClick(team.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
teamEl.append(labelEl, ruleEl, countEl);
|
||||||
|
containerLeft.appendChild(teamEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
||||||
|
const SPECIES_LABELS = {
|
||||||
|
bear: "곰",
|
||||||
|
human: "인간",
|
||||||
|
orc: "오크",
|
||||||
|
skeleton: "해골",
|
||||||
|
slime: "슬라임",
|
||||||
|
wolf: "늑대",
|
||||||
|
};
|
||||||
|
const SPECIES_SUBJECT_PARTICLES = {
|
||||||
|
bear: "이",
|
||||||
|
human: "이",
|
||||||
|
orc: "가",
|
||||||
|
skeleton: "이",
|
||||||
|
slime: "이",
|
||||||
|
wolf: "가",
|
||||||
|
};
|
||||||
|
const DEATH_NOTICE_TEMPLATES = [
|
||||||
|
"오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
|
||||||
|
"{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
|
||||||
|
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
|
||||||
|
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BATTLE_NOTICE_DELAY_MS = 5000;
|
||||||
|
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
|
||||||
|
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
|
export function createDeathCounts() {
|
||||||
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
|
counts[species] = 0;
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDeathCounts(value = {}) {
|
||||||
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
|
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDeathCounts(baseCounts, matchCounts) {
|
||||||
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
|
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSpecies(value) {
|
||||||
|
return SPECIES_KEYS.includes(value) ? value : "human";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
||||||
|
const topSpecies = SPECIES_KEYS
|
||||||
|
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
|
||||||
|
.sort((left, right) => right.count - left.count)[0];
|
||||||
|
|
||||||
|
if (!topSpecies || topSpecies.count === 0) {
|
||||||
|
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = DEATH_NOTICE_TEMPLATES[
|
||||||
|
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
||||||
|
];
|
||||||
|
|
||||||
|
return template
|
||||||
|
.replace("{species}", SPECIES_LABELS[topSpecies.species])
|
||||||
|
.replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
|
||||||
|
.replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBattleDeathNotice(noticeNode, message) {
|
||||||
|
if (!noticeNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noticeNode.textContent = message;
|
||||||
|
noticeNode.classList.add("is-visible");
|
||||||
|
noticeNode.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBattleNotice(noticeNode) {
|
||||||
|
if (!noticeNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noticeNode.classList.remove("is-visible");
|
||||||
|
noticeNode.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||||
|
const VICTORY_CONFETTI_COUNT = 40;
|
||||||
|
const VICTORY_FANFARE_NOTES = [
|
||||||
|
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||||
|
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
||||||
|
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
|
||||||
|
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
|
||||||
|
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
|
||||||
|
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
|
||||||
|
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
|
||||||
|
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
|
||||||
|
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createVictoryConfettiPiece(index) {
|
||||||
|
const piece = document.createElement("i");
|
||||||
|
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
|
||||||
|
const distance = 170 + (index % 8) * 26;
|
||||||
|
const x = Math.round(Math.cos(angle) * distance);
|
||||||
|
const y = Math.round(Math.sin(angle) * distance * 0.78);
|
||||||
|
|
||||||
|
piece.className = "victory-confetti-piece";
|
||||||
|
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
|
||||||
|
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
|
||||||
|
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
|
||||||
|
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
|
||||||
|
piece.style.setProperty("--confetti-x", `${x}px`);
|
||||||
|
piece.style.setProperty("--confetti-y", `${y}px`);
|
||||||
|
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
|
||||||
|
|
||||||
|
return piece;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
|
||||||
|
|
||||||
|
let victoryAudioContext = null;
|
||||||
|
|
||||||
|
export function removeVictoryCelebration() {
|
||||||
|
document.querySelector(".victory-celebration")?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVictoryCelebration(message) {
|
||||||
|
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
|
||||||
|
|
||||||
|
if (!celebrationHost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVictory = message.includes("승리");
|
||||||
|
const celebration = document.createElement("div");
|
||||||
|
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
|
||||||
|
celebration.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const rays = document.createElement("span");
|
||||||
|
rays.className = "victory-rays";
|
||||||
|
|
||||||
|
const confetti = document.createElement("span");
|
||||||
|
confetti.className = "victory-confetti";
|
||||||
|
|
||||||
|
if (isVictory) {
|
||||||
|
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
|
||||||
|
confetti.appendChild(createVictoryConfettiPiece(index));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = document.createElement("div");
|
||||||
|
banner.className = "victory-banner";
|
||||||
|
|
||||||
|
const messageNode = document.createElement("span");
|
||||||
|
messageNode.className = "victory-banner-message";
|
||||||
|
messageNode.textContent = message;
|
||||||
|
|
||||||
|
banner.appendChild(messageNode);
|
||||||
|
celebration.append(rays, confetti, banner);
|
||||||
|
celebrationHost.appendChild(celebration);
|
||||||
|
|
||||||
|
if (isVictory) {
|
||||||
|
playVictoryFanfare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primeVictoryFanfareAudio() {
|
||||||
|
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||||
|
|
||||||
|
if (!AudioContextClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!victoryAudioContext) {
|
||||||
|
victoryAudioContext = new AudioContextClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (victoryAudioContext.state === "suspended") {
|
||||||
|
victoryAudioContext.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return victoryAudioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playVictoryFanfare() {
|
||||||
|
const audioContext = primeVictoryFanfareAudio();
|
||||||
|
|
||||||
|
if (!audioContext || audioContext.state !== "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = audioContext.currentTime + 0.03;
|
||||||
|
|
||||||
|
VICTORY_FANFARE_NOTES.forEach((note) => {
|
||||||
|
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gain = audioContext.createGain();
|
||||||
|
const releaseAt = startAt + duration;
|
||||||
|
|
||||||
|
oscillator.type = "triangle";
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, startAt);
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.0001, startAt);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
|
||||||
|
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(audioContext.destination);
|
||||||
|
oscillator.start(startAt);
|
||||||
|
oscillator.stop(releaseAt + 0.02);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue