Add battle death stats and HUD updates
This commit is contained in:
@@ -39,6 +39,8 @@ export const KILL_HEAL_EFFECT_FRAMES = 4;
|
||||
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
|
||||
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
|
||||
export const KILL_GROWTH_MULTIPLIER = 1.25;
|
||||
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
|
||||
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
|
||||
// 처치 성장 연출 tween 지속 시간(ms)입니다.
|
||||
export const KILL_GROWTH_TWEEN_DURATION = 180;
|
||||
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
|
||||
@@ -152,3 +154,50 @@ export const TEAM_COLORS = [
|
||||
"#63c5a6",
|
||||
"#d98755",
|
||||
];
|
||||
|
||||
const TEAM_COLOR_GOLDEN_ANGLE = 137.508;
|
||||
const TEAM_COLOR_HUE_OFFSET = 12;
|
||||
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
|
||||
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
|
||||
|
||||
export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
|
||||
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
|
||||
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
|
||||
|
||||
if (safeTeamCount <= TEAM_COLORS.length) {
|
||||
return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
|
||||
}
|
||||
|
||||
const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
|
||||
const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
|
||||
const lightness =
|
||||
TEAM_COLOR_LIGHTNESSES[
|
||||
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length
|
||||
];
|
||||
|
||||
return hslToHex(hue, saturation, lightness);
|
||||
}
|
||||
|
||||
function hslToHex(hue, saturation, lightness) {
|
||||
const normalizedSaturation = saturation / 100;
|
||||
const normalizedLightness = lightness / 100;
|
||||
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
|
||||
const huePrime = hue / 60;
|
||||
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
|
||||
const match = normalizedLightness - chroma / 2;
|
||||
const [red, green, blue] = huePrime < 1
|
||||
? [chroma, x, 0]
|
||||
: huePrime < 2
|
||||
? [x, chroma, 0]
|
||||
: huePrime < 3
|
||||
? [0, chroma, x]
|
||||
: huePrime < 4
|
||||
? [0, x, chroma]
|
||||
: huePrime < 5
|
||||
? [x, 0, chroma]
|
||||
: [chroma, 0, x];
|
||||
|
||||
return `#${[red, green, blue]
|
||||
.map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
+567
-20
@@ -23,6 +23,7 @@ import { createFighter, syncFighterHud } from "./fighterFactory.js";
|
||||
import { fighterManifest } from "./fighterManifest.js";
|
||||
import { pickFighters } from "./fighterSelection.js";
|
||||
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
|
||||
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
|
||||
|
||||
export class ArenaScene extends Phaser.Scene {
|
||||
constructor({ getInitialMatchConfig, setStatus }) {
|
||||
@@ -31,23 +32,35 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.getInitialMatchConfig = getInitialMatchConfig;
|
||||
this.matchId = 0;
|
||||
this.matchOver = false;
|
||||
this.matchPaused = false;
|
||||
this.presentationMode = true;
|
||||
this.ready = false;
|
||||
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
|
||||
this.setStatus = (message) => {
|
||||
// 기존 배너 제거
|
||||
this.updateStatus(message);
|
||||
|
||||
const oldBanner = document.querySelector(".victory-banner");
|
||||
if (oldBanner) oldBanner.remove();
|
||||
|
||||
// 승리 또는 무승부 메시지인 경우 전용 배너 생성
|
||||
if (message.includes("승리") || message.includes("무승부")) {
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "victory-banner";
|
||||
banner.textContent = message;
|
||||
document.querySelector(".arena-shell").appendChild(banner);
|
||||
document.querySelector(".arena-shell")?.appendChild(banner);
|
||||
}
|
||||
};
|
||||
this.observedCombat = [];
|
||||
this.selectedFighter = null;
|
||||
this.teams = [];
|
||||
this.killLogNode = null;
|
||||
this.killLogListNode = null;
|
||||
this.battleNoticeHideTimer = null;
|
||||
this.battleNoticeNode = null;
|
||||
this.battleNoticeSequence = 0;
|
||||
this.battleNoticeTimer = null;
|
||||
this.battleDeathCounts = createDeathCounts();
|
||||
this.deathStatsBaseline = createDeathCounts();
|
||||
this.deathStatsSaved = false;
|
||||
}
|
||||
|
||||
preload() {
|
||||
@@ -99,10 +112,10 @@ export class ArenaScene extends Phaser.Scene {
|
||||
});
|
||||
|
||||
this.ready = true;
|
||||
this.startMatch(this.getInitialMatchConfig());
|
||||
this.startMatch(this.getInitialMatchConfig(), { silent: true });
|
||||
}
|
||||
|
||||
startMatch({ names = [], teamSize } = {}) {
|
||||
startMatch({ names = [], teamSize } = {}, { silent = false } = {}) {
|
||||
if (!this.ready) {
|
||||
return;
|
||||
}
|
||||
@@ -114,29 +127,257 @@ export class ArenaScene extends Phaser.Scene {
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize);
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins);
|
||||
syncTeamSizes(matchSetup.teams, fighterPlans);
|
||||
|
||||
this.matchId += 1;
|
||||
this.matchOver = false;
|
||||
this.setPaused(false, { silent: true });
|
||||
this.presentationMode = silent;
|
||||
this.resetMatchDeathStats({ silent });
|
||||
this.observedCombat = [];
|
||||
this.clearSelectedFighter();
|
||||
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
|
||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
clearCombatObjects(this);
|
||||
this.fighters.forEach((fighter) => fighter.destroy());
|
||||
this.resetKillLog();
|
||||
this.teams = matchSetup.teams;
|
||||
this.fighters = matchSetup.fighters.map((fighterSetup, index) =>
|
||||
createFighter(this, {
|
||||
...fighterSetup,
|
||||
skin: matchSkins[index],
|
||||
}),
|
||||
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
|
||||
|
||||
if (!silent) {
|
||||
this.setStatus(matchStatusText(this.teams));
|
||||
} else {
|
||||
this.focusPresentationCombat();
|
||||
}
|
||||
|
||||
this.updateScoreboard();
|
||||
}
|
||||
|
||||
spawnSplitFighters(source, splitOnDeath) {
|
||||
const count = Math.max(0, Math.round(splitOnDeath.count ?? 0));
|
||||
const childMaxHp = Math.max(1, Math.round(splitOnDeath.childMaxHp ?? 1));
|
||||
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = Array.from({ length: count }, (_, index) => {
|
||||
const position = clusterSpawnPosition(source, index, count);
|
||||
|
||||
return createFighter(this, {
|
||||
canSplitOnDeath: Boolean(splitOnDeath.childCanSplit),
|
||||
faceLeft: source.flipX,
|
||||
hp: childMaxHp,
|
||||
maxHp: childMaxHp,
|
||||
name: source.name,
|
||||
skin: source.skin,
|
||||
team: source.team,
|
||||
teamIndex: source.teamIndex,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
});
|
||||
|
||||
this.fighters.push(...children);
|
||||
|
||||
const team = this.teams.find((candidate) => candidate.id === source.team.id);
|
||||
if (team) {
|
||||
team.size += children.length;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
resetKillLog() {
|
||||
const { logNode, listNode } = this.getKillLogNodes();
|
||||
|
||||
if (listNode) {
|
||||
listNode.replaceChildren();
|
||||
}
|
||||
|
||||
logNode?.classList.remove("has-entries");
|
||||
logNode?.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
resetMatchDeathStats({ silent = false } = {}) {
|
||||
this.clearBattleNotice();
|
||||
this.battleDeathCounts = createDeathCounts();
|
||||
this.battleNoticeSequence = 0;
|
||||
this.deathStatsBaseline = createDeathCounts();
|
||||
this.deathStatsSaved = false;
|
||||
|
||||
if (!silent) {
|
||||
this.loadTodayDeathStats();
|
||||
this.scheduleBattleNotice();
|
||||
}
|
||||
}
|
||||
|
||||
loadTodayDeathStats() {
|
||||
const activeMatchId = this.matchId;
|
||||
|
||||
fetchTodayDeathStats()
|
||||
.then((stats) => {
|
||||
if (this.matchId !== activeMatchId || this.presentationMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deathStatsBaseline = normalizeDeathCounts(stats?.deathsBySpecies);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(error);
|
||||
});
|
||||
}
|
||||
|
||||
scheduleBattleNotice(delayMs = BATTLE_NOTICE_DELAY_MS) {
|
||||
this.battleNoticeTimer?.remove(false);
|
||||
this.battleNoticeTimer = this.time.delayedCall(delayMs, () => {
|
||||
this.battleNoticeTimer = null;
|
||||
|
||||
if (!this.matchOver && !this.presentationMode) {
|
||||
this.showBattleDeathNotice();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearBattleNotice() {
|
||||
this.battleNoticeTimer?.remove(false);
|
||||
this.battleNoticeHideTimer?.remove(false);
|
||||
this.battleNoticeTimer = null;
|
||||
this.battleNoticeHideTimer = null;
|
||||
|
||||
const noticeNode = this.getBattleNoticeNode();
|
||||
|
||||
noticeNode?.classList.remove("is-visible");
|
||||
noticeNode?.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
recordDeath(fighter) {
|
||||
if (this.presentationMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const species = normalizeSpecies(fighter?.skin?.species);
|
||||
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1;
|
||||
}
|
||||
|
||||
showBattleDeathNotice() {
|
||||
const noticeNode = this.getBattleNoticeNode();
|
||||
|
||||
if (!noticeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
noticeNode.textContent = createDeathNoticeMessage(
|
||||
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
|
||||
this.matchId + this.battleNoticeSequence,
|
||||
);
|
||||
this.battleNoticeSequence += 1;
|
||||
noticeNode.classList.add("is-visible");
|
||||
noticeNode.setAttribute("aria-hidden", "false");
|
||||
|
||||
this.battleNoticeHideTimer?.remove(false);
|
||||
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
|
||||
this.battleNoticeHideTimer = null;
|
||||
noticeNode.classList.remove("is-visible");
|
||||
noticeNode.setAttribute("aria-hidden", "true");
|
||||
|
||||
if (!this.matchOver && !this.presentationMode) {
|
||||
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getBattleNoticeNode() {
|
||||
this.battleNoticeNode ??= document.getElementById("battle-notice");
|
||||
return this.battleNoticeNode;
|
||||
}
|
||||
|
||||
persistDailyDeathStats() {
|
||||
if (this.deathStatsSaved || this.presentationMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deathStatsSaved = true;
|
||||
|
||||
addTodayDeathStats({
|
||||
deathsBySpecies: { ...this.battleDeathCounts },
|
||||
})
|
||||
.then((result) => {
|
||||
this.deathStatsBaseline = normalizeDeathCounts(result?.today?.deathsBySpecies);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(error);
|
||||
});
|
||||
}
|
||||
|
||||
recordKill(winner, defender) {
|
||||
if (this.presentationMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordDeath(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}`,
|
||||
);
|
||||
|
||||
this.setStatus(matchStatusText(this.teams));
|
||||
this.updateScoreboard();
|
||||
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() {
|
||||
this.killLogNode ??= document.getElementById("kill-log");
|
||||
this.killLogListNode ??= document.getElementById("kill-log-list");
|
||||
|
||||
return {
|
||||
logNode: this.killLogNode,
|
||||
listNode: this.killLogListNode,
|
||||
};
|
||||
}
|
||||
update(time) {
|
||||
this.fighters.forEach(syncFighterHud);
|
||||
|
||||
if (this.matchPaused) {
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.matchOver) {
|
||||
this.fighters.forEach((fighter) => {
|
||||
updateFighter(this, fighter, time, () => {
|
||||
@@ -146,6 +387,13 @@ update(time) {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.presentationMode) {
|
||||
this.followPresentationCombat();
|
||||
this.minimapCamera?.setAlpha(0);
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.focusSelectedFighter()) {
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
@@ -229,6 +477,109 @@ update(time) {
|
||||
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
|
||||
}
|
||||
|
||||
isMatchPaused() {
|
||||
return this.matchPaused;
|
||||
}
|
||||
|
||||
togglePause() {
|
||||
return this.setPaused(!this.matchPaused);
|
||||
}
|
||||
|
||||
setPaused(paused, { silent = false } = {}) {
|
||||
const nextPaused = Boolean(paused) && this.ready && !this.matchOver && !this.presentationMode;
|
||||
|
||||
if (this.matchPaused === nextPaused) {
|
||||
return this.matchPaused;
|
||||
}
|
||||
|
||||
this.matchPaused = nextPaused;
|
||||
|
||||
if (nextPaused) {
|
||||
this.physics.pause();
|
||||
this.time.paused = true;
|
||||
this.tweens.pauseAll?.();
|
||||
} else {
|
||||
this.physics.resume();
|
||||
this.time.paused = false;
|
||||
this.tweens.resumeAll?.();
|
||||
}
|
||||
|
||||
this.setSceneAnimationsPaused(nextPaused);
|
||||
|
||||
if (!silent && !this.presentationMode) {
|
||||
this.setStatus(nextPaused ? "일시정지" : matchStatusText(this.teams));
|
||||
}
|
||||
|
||||
return this.matchPaused;
|
||||
}
|
||||
|
||||
setSceneAnimationsPaused(paused) {
|
||||
const animatedObjects = [
|
||||
...this.fighters,
|
||||
...(this.combatObjects ? Array.from(this.combatObjects) : []),
|
||||
];
|
||||
|
||||
animatedObjects.forEach((object) => {
|
||||
if (!object?.anims) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
object.anims.pause();
|
||||
} else {
|
||||
object.anims.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectRandomTeamFighter(teamId) {
|
||||
if (this.matchOver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = this.fighters.filter(
|
||||
(fighter) => isLivingFighter(fighter) && fighter.team.id === teamId,
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fighter = candidates[Phaser.Math.Between(0, candidates.length - 1)];
|
||||
this.selectFighter(fighter);
|
||||
this.setStatus(`${fighter.team.label} 시점: ${fighter.fighterName ?? fighter.name}`);
|
||||
this.updateScoreboard();
|
||||
}
|
||||
|
||||
focusPresentationCombat() {
|
||||
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
|
||||
this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
|
||||
|
||||
const combatCenter = this.getObservedCombatCenter();
|
||||
if (combatCenter) {
|
||||
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
|
||||
}
|
||||
|
||||
this.minimapCamera?.setAlpha(0);
|
||||
this.updateMinimapViewportFrame();
|
||||
}
|
||||
|
||||
followPresentationCombat() {
|
||||
if (this.cameras.main.zoom !== SPECTATOR_LATE_FIGHT_ZOOM) {
|
||||
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
|
||||
}
|
||||
|
||||
const combatCenter = this.getObservedCombatCenter();
|
||||
if (!combatCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cameras.main.scrollX +=
|
||||
(Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
|
||||
this.cameras.main.scrollY +=
|
||||
(Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
|
||||
}
|
||||
|
||||
setMainCameraZoom(zoom) {
|
||||
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
||||
|
||||
@@ -315,22 +666,41 @@ update(time) {
|
||||
scoreLeft.innerHTML = "";
|
||||
scoreRight.innerHTML = "";
|
||||
|
||||
this.teams.forEach((team, index) => {
|
||||
this.teams.forEach((team) => {
|
||||
const aliveCount = this.fighters.filter(
|
||||
(f) => f.team.id === team.id && !f.isDead
|
||||
).length;
|
||||
|
||||
const teamEl = document.createElement("div");
|
||||
const teamEl = document.createElement("button");
|
||||
teamEl.className = "team-score";
|
||||
teamEl.style.backgroundColor = `${team.color}44`; // 44 is alpha for 26%
|
||||
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}`;
|
||||
teamEl.innerHTML = `<span>${team.label}</span> <span>${aliveCount}</span>`;
|
||||
|
||||
if (index % 2 === 0) {
|
||||
scoreLeft.appendChild(teamEl);
|
||||
} else {
|
||||
scoreRight.appendChild(teamEl);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -350,6 +720,19 @@ update(time) {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.presentationMode) {
|
||||
const finishedMatchId = this.matchId;
|
||||
this.time.delayedCall(1200, () => {
|
||||
if (this.presentationMode && this.matchId === finishedMatchId) {
|
||||
this.startMatch(this.getInitialMatchConfig(), { silent: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBattleNotice();
|
||||
this.persistDailyDeathStats();
|
||||
|
||||
if (livingTeams.size === 1) {
|
||||
const winningTeamId = Array.from(livingTeams)[0];
|
||||
const winningTeam = this.teams.find((team) => team.id === winningTeamId);
|
||||
@@ -360,6 +743,170 @@ 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}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||
];
|
||||
|
||||
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) {
|
||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||
const skin = skins[index];
|
||||
const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 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;
|
||||
|
||||
+48
-1
@@ -1,5 +1,6 @@
|
||||
import Phaser from "phaser";
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
ATTACK_COOLDOWN,
|
||||
ATTACK_DAMAGE_MAX,
|
||||
ATTACK_DAMAGE_MIN,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
FIGHTER_MAX_HP,
|
||||
FIGHTER_SCALE,
|
||||
KILL_HEALTH_RECOVERY_RATIO,
|
||||
KILL_GROWTH_MAX_MULTIPLIER,
|
||||
KILL_GROWTH_MULTIPLIER,
|
||||
KILL_GROWTH_TWEEN_DURATION,
|
||||
MELEE_HIT_DELAY,
|
||||
@@ -351,14 +353,37 @@ function killFighter(defender, winner, onWinner) {
|
||||
winner.isLocked = false;
|
||||
winner.body.setVelocity(0, 0);
|
||||
playAnimation(winner, "idle");
|
||||
winner.scene.recordKill?.(winner, defender);
|
||||
applyKillReward(winner);
|
||||
maybeSplitFighter(defender);
|
||||
onWinner(winner);
|
||||
}
|
||||
|
||||
function maybeSplitFighter(fighter) {
|
||||
const splitOnDeath = fighter.canSplitOnDeath === false
|
||||
? null
|
||||
: fighter.skin.traits?.splitOnDeath;
|
||||
|
||||
if (!splitOnDeath || typeof fighter.scene.spawnSplitFighters !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const chance = Phaser.Math.Clamp(Number(splitOnDeath.chance ?? 1), 0, 1);
|
||||
|
||||
if (Math.random() >= chance) {
|
||||
return;
|
||||
}
|
||||
|
||||
fighter.scene.spawnSplitFighters(fighter, splitOnDeath);
|
||||
}
|
||||
|
||||
function applyKillReward(winner) {
|
||||
winner.killCount = (winner.killCount ?? 0) + 1;
|
||||
|
||||
const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount;
|
||||
const rewardMultiplier = Math.min(
|
||||
KILL_GROWTH_MAX_MULTIPLIER,
|
||||
KILL_GROWTH_MULTIPLIER ** winner.killCount,
|
||||
);
|
||||
const previousHp = winner.hp;
|
||||
const nextHp = recoveredHealth(winner);
|
||||
winner.killRewardMultiplier = rewardMultiplier;
|
||||
@@ -377,9 +402,31 @@ function applyKillReward(winner) {
|
||||
scaleY: nextScaleY,
|
||||
duration: KILL_GROWTH_TWEEN_DURATION,
|
||||
ease: "Back.Out",
|
||||
onUpdate: () => clampFighterInsideArena(winner),
|
||||
onComplete: () => clampFighterInsideArena(winner),
|
||||
});
|
||||
}
|
||||
|
||||
function clampFighterInsideArena(fighter) {
|
||||
if (!fighter?.active || !fighter.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const halfWidth = Math.min(
|
||||
ARENA_SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
|
||||
);
|
||||
const halfHeight = Math.min(
|
||||
ARENA_SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
|
||||
);
|
||||
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA_SIZE - halfWidth);
|
||||
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA_SIZE - halfHeight);
|
||||
|
||||
fighter.setPosition(x, y);
|
||||
fighter.body.updateFromGameObject?.();
|
||||
}
|
||||
|
||||
function recoveredHealth(fighter) {
|
||||
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
|
||||
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
KILL_HEAL_EFFECT_FRAME_RATE,
|
||||
KILL_HEAL_EFFECT_FRAMES,
|
||||
SELECTED_FIGHTER_OUTLINE_ALPHA,
|
||||
SELECTED_FIGHTER_OUTLINE_BLUE,
|
||||
SELECTED_FIGHTER_OUTLINE_GAP,
|
||||
SELECTED_FIGHTER_OUTLINE_GREEN,
|
||||
SELECTED_FIGHTER_OUTLINE_RED,
|
||||
SELECTED_FIGHTER_OUTLINE_WIDTH,
|
||||
} from "../constants.js";
|
||||
|
||||
@@ -261,9 +258,9 @@ function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
||||
|
||||
const outlineIndex = maskIndex * 4;
|
||||
|
||||
outlineData[outlineIndex] = SELECTED_FIGHTER_OUTLINE_RED;
|
||||
outlineData[outlineIndex + 1] = SELECTED_FIGHTER_OUTLINE_GREEN;
|
||||
outlineData[outlineIndex + 2] = SELECTED_FIGHTER_OUTLINE_BLUE;
|
||||
outlineData[outlineIndex] = 255;
|
||||
outlineData[outlineIndex + 1] = 255;
|
||||
outlineData[outlineIndex + 2] = 255;
|
||||
outlineData[outlineIndex + 3] = outlineAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-18
@@ -17,9 +17,21 @@ import {
|
||||
|
||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
||||
|
||||
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
|
||||
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;
|
||||
const displayName = name || team.label;
|
||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? skin.stats?.maxHp ?? FIGHTER_MAX_HP));
|
||||
const resolvedHp = Math.min(
|
||||
resolvedMaxHp,
|
||||
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
||||
);
|
||||
|
||||
fighter.setScale(FIGHTER_SCALE);
|
||||
fighter.setName(displayName);
|
||||
fighter.setDepth(2);
|
||||
fighter.setCollideWorldBounds(true);
|
||||
fighter.setFlipX(faceLeft);
|
||||
@@ -35,14 +47,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
fighter.input.cursor = "pointer";
|
||||
fighter.selectionOutline = scene.add
|
||||
|
||||
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(false);
|
||||
.setVisible(true);
|
||||
|
||||
fighter.nameLabel = scene.add
|
||||
.text(x, y, name, {
|
||||
.text(x, y, displayName, {
|
||||
color: "#fff2c2",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "18px",
|
||||
@@ -61,15 +76,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
.setDepth(5);
|
||||
|
||||
fighter.skin = skin;
|
||||
fighter.fighterName = displayName;
|
||||
fighter.team = team;
|
||||
fighter.teamIndex = teamIndex;
|
||||
fighter.baseScaleX = FIGHTER_SCALE;
|
||||
fighter.baseScaleY = FIGHTER_SCALE;
|
||||
fighter.canSplitOnDeath = canSplitOnDeath;
|
||||
fighter.isSelected = false;
|
||||
fighter.killCount = 0;
|
||||
fighter.killRewardMultiplier = 1;
|
||||
fighter.maxHp = FIGHTER_MAX_HP;
|
||||
fighter.hp = fighter.maxHp;
|
||||
fighter.maxHp = resolvedMaxHp;
|
||||
fighter.hp = resolvedHp;
|
||||
fighter.nextAttackAt = 0;
|
||||
fighter.isLocked = false;
|
||||
fighter.isDead = false;
|
||||
@@ -102,18 +119,18 @@ export function syncFighterHud(fighter) {
|
||||
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 ?? FIGHTER_MAX_HP)));
|
||||
syncSelectionOutline(fighter);
|
||||
syncTeamMarker(fighter);
|
||||
}
|
||||
|
||||
function syncSelectionOutline(fighter) {
|
||||
const outline = fighter.selectionOutline;
|
||||
function syncTeamMarker(fighter) {
|
||||
const marker = fighter.teamMarker;
|
||||
|
||||
if (!outline) {
|
||||
if (!marker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = Boolean(fighter.isSelected && !fighter.isDead);
|
||||
outline.setVisible(isVisible);
|
||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||
marker.setVisible(isVisible);
|
||||
|
||||
if (!isVisible) {
|
||||
return;
|
||||
@@ -122,20 +139,20 @@ function syncSelectionOutline(fighter) {
|
||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
||||
|
||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
||||
outline.setTexture(outlineTextureKey, fighter.frame.name);
|
||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
||||
}
|
||||
|
||||
outline.setPosition(fighter.x, fighter.y);
|
||||
outline.setScale(fighter.scaleX, fighter.scaleY);
|
||||
outline.setFlipX(fighter.flipX);
|
||||
outline.setDepth(fighter.depth - 0.1);
|
||||
marker.setPosition(fighter.x, fighter.y);
|
||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
||||
marker.setFlipX(fighter.flipX);
|
||||
marker.setDepth(fighter.depth - 0.1);
|
||||
}
|
||||
|
||||
function attachHudCleanup(fighter) {
|
||||
const originalDestroy = fighter.destroy.bind(fighter);
|
||||
|
||||
fighter.destroy = (...args) => {
|
||||
fighter.selectionOutline.destroy();
|
||||
fighter.teamMarker.destroy();
|
||||
fighter.nameLabel.destroy();
|
||||
fighter.healthBack.destroy();
|
||||
fighter.healthBar.destroy();
|
||||
|
||||
@@ -4,6 +4,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "knight",
|
||||
label: "Knight",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/knight",
|
||||
animations: {
|
||||
idle: animation("Knight-Idle.png", 6),
|
||||
@@ -19,6 +20,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "orc",
|
||||
label: "Orc",
|
||||
species: "orc",
|
||||
assetRoot: "assets/characters/orc",
|
||||
animations: {
|
||||
idle: animation("Orc-Idle.png", 6),
|
||||
@@ -32,6 +34,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "archer",
|
||||
label: "Archer",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/archer",
|
||||
combat: {
|
||||
projectile: {
|
||||
@@ -51,6 +54,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "armored-axeman",
|
||||
label: "Armored Axeman",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/armored-axeman",
|
||||
animations: {
|
||||
idle: animation("Armored Axeman-Idle.png", 6),
|
||||
@@ -65,6 +69,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "armored-orc",
|
||||
label: "Armored Orc",
|
||||
species: "orc",
|
||||
assetRoot: "assets/characters/armored-orc",
|
||||
animations: {
|
||||
idle: animation("Armored Orc-Idle.png", 6),
|
||||
@@ -80,6 +85,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "armored-skeleton",
|
||||
label: "Armored Skeleton",
|
||||
species: "skeleton",
|
||||
assetRoot: "assets/characters/armored-skeleton",
|
||||
animations: {
|
||||
idle: animation("Armored Skeleton-Idle.png", 6),
|
||||
@@ -93,6 +99,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "elite-orc",
|
||||
label: "Elite Orc",
|
||||
species: "orc",
|
||||
assetRoot: "assets/characters/elite-orc",
|
||||
animations: {
|
||||
idle: animation("Elite Orc-Idle.png", 6),
|
||||
@@ -107,6 +114,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "greatsword-skeleton",
|
||||
label: "Greatsword Skeleton",
|
||||
species: "skeleton",
|
||||
assetRoot: "assets/characters/greatsword-skeleton",
|
||||
animations: {
|
||||
idle: animation("Greatsword Skeleton-Idle.png", 6),
|
||||
@@ -121,6 +129,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "knight-templar",
|
||||
label: "Knight Templar",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/knight-templar",
|
||||
animations: {
|
||||
idle: animation("Knight Templar-Idle.png", 6),
|
||||
@@ -137,6 +146,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "lancer",
|
||||
label: "Lancer",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/lancer",
|
||||
animations: {
|
||||
idle: animation("Lancer-Idle.png", 6),
|
||||
@@ -152,6 +162,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "orc-rider",
|
||||
label: "Orc rider",
|
||||
species: "orc",
|
||||
assetRoot: "assets/characters/orc-rider",
|
||||
animations: {
|
||||
idle: animation("Orc rider-Idle.png", 6),
|
||||
@@ -167,6 +178,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "priest",
|
||||
label: "Priest",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/priest",
|
||||
combat: {
|
||||
attackEffect: {
|
||||
@@ -187,6 +199,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "skeleton",
|
||||
label: "Skeleton",
|
||||
species: "skeleton",
|
||||
assetRoot: "assets/characters/skeleton",
|
||||
animations: {
|
||||
idle: animation("Skeleton-Idle.png", 6),
|
||||
@@ -201,6 +214,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "skeleton-archer",
|
||||
label: "Skeleton Archer",
|
||||
species: "skeleton",
|
||||
assetRoot: "assets/characters/skeleton-archer",
|
||||
combat: {
|
||||
projectile: {
|
||||
@@ -219,7 +233,20 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "slime",
|
||||
label: "Slime",
|
||||
species: "slime",
|
||||
assetRoot: "assets/characters/slime",
|
||||
stats: {
|
||||
maxHp: 1,
|
||||
},
|
||||
traits: {
|
||||
spawnMultiplier: 10,
|
||||
splitOnDeath: {
|
||||
chance: 0.5,
|
||||
count: 2,
|
||||
childMaxHp: 1,
|
||||
childCanSplit: false,
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
idle: animation("Slime-Idle.png", 6),
|
||||
walk: animation("Slime-Walk.png", 6),
|
||||
@@ -232,6 +259,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "soldier-close",
|
||||
label: "Soldier Close",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/soldier",
|
||||
combat: {
|
||||
type: "melee",
|
||||
@@ -248,6 +276,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "soldier-range",
|
||||
label: "Soldier Range",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/soldier",
|
||||
combat: {
|
||||
projectile: {
|
||||
@@ -266,6 +295,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "swordsman",
|
||||
label: "Swordsman",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/swordsman",
|
||||
animations: {
|
||||
idle: animation("Swordsman-Idle.png", 6),
|
||||
@@ -280,6 +310,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "werebear",
|
||||
label: "Werebear",
|
||||
species: "bear",
|
||||
assetRoot: "assets/characters/werebear",
|
||||
animations: {
|
||||
idle: animation("Werebear-Idle.png", 6),
|
||||
@@ -294,6 +325,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "werewolf",
|
||||
label: "Werewolf",
|
||||
species: "wolf",
|
||||
assetRoot: "assets/characters/werewolf",
|
||||
animations: {
|
||||
idle: animation("Werewolf-Idle.png", 6),
|
||||
@@ -307,6 +339,7 @@ export const fighterManifest = [
|
||||
{
|
||||
key: "wizard",
|
||||
label: "Wizard",
|
||||
species: "human",
|
||||
assetRoot: "assets/characters/wizard",
|
||||
combat: {
|
||||
attackEffect: {
|
||||
|
||||
+12
-6
@@ -2,15 +2,15 @@ import {
|
||||
ARENA_SIZE,
|
||||
DEFAULT_TEAM_SIZE,
|
||||
GRID_SIZE,
|
||||
getTeamColor,
|
||||
MAX_TEAM_SIZE,
|
||||
TEAM_COLORS,
|
||||
TILE_SIZE,
|
||||
} from "../constants.js";
|
||||
|
||||
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||
const teams = names.map((name, index) => ({
|
||||
color: TEAM_COLORS[index % TEAM_COLORS.length],
|
||||
color: getTeamColor(index, names.length),
|
||||
id: `team-${index + 1}`,
|
||||
label: name,
|
||||
size: teamSize,
|
||||
@@ -39,13 +39,19 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
}
|
||||
|
||||
export function matchStatusText(teams) {
|
||||
const teamSizes = teams.map((team) => team.size).join(", ");
|
||||
return `${teams.length}팀 전투: ${teamSizes}`;
|
||||
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||
const teamSizes = new Set(teams.map((team) => team.size));
|
||||
const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}명` : "팀별 가변 인원";
|
||||
const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
|
||||
|
||||
return `${teams.length}팀 | ${teamSizeText} | 총 ${totalFighters}명 출전 | ${labels}`;
|
||||
}
|
||||
|
||||
function createTeams(playerCount, teamSize) {
|
||||
return Array.from({ length: Math.ceil(playerCount / teamSize) }, (_, index) => ({
|
||||
color: TEAM_COLORS[index % TEAM_COLORS.length],
|
||||
const teamCount = Math.ceil(playerCount / teamSize);
|
||||
|
||||
return Array.from({ length: teamCount }, (_, index) => ({
|
||||
color: getTeamColor(index, teamCount),
|
||||
id: `team-${index + 1}`,
|
||||
label: `Team ${index + 1}`,
|
||||
size: Math.min(teamSize, playerCount - index * teamSize),
|
||||
|
||||
+120
-2
@@ -3,9 +3,123 @@ import { ArenaScene } from "./game/ArenaScene.js";
|
||||
import { ARENA_SIZE } from "./constants.js";
|
||||
import { createMatchForm } from "./ui/matchForm.js";
|
||||
import { trackVisitor } from "./ui/visitorCounter.js";
|
||||
import "./styles.css";
|
||||
|
||||
const matchForm = createMatchForm();
|
||||
const appNode = document.querySelector("#app");
|
||||
const startButton = document.querySelector("#start-button");
|
||||
const drawer = document.querySelector("#fighter-entry");
|
||||
const drawerCloseButton = document.querySelector("#drawer-close");
|
||||
const drawerScrim = document.querySelector("#drawer-scrim");
|
||||
const drawerToggleButton = document.querySelector("#drawer-toggle");
|
||||
const playerNamesInput = document.querySelector("#player-names");
|
||||
const pauseButton = document.querySelector("#pause-button");
|
||||
const restartButton = document.querySelector("#restart-button");
|
||||
|
||||
function isMatchLive() {
|
||||
return appNode?.classList.contains("match-live") ?? false;
|
||||
}
|
||||
|
||||
function openOptionsDrawer({ focus = true } = {}) {
|
||||
appNode?.classList.add("options-open");
|
||||
setDrawerCollapsed(false);
|
||||
drawer?.setAttribute("aria-hidden", "false");
|
||||
startButton?.setAttribute("aria-expanded", "true");
|
||||
|
||||
if (focus) {
|
||||
window.setTimeout(() => playerNamesInput?.focus(), 220);
|
||||
}
|
||||
}
|
||||
|
||||
function closeOptionsDrawer() {
|
||||
if (isMatchLive()) {
|
||||
setDrawerCollapsed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
appNode?.classList.remove("options-open");
|
||||
appNode?.classList.remove("drawer-collapsed");
|
||||
drawer?.setAttribute("aria-hidden", "true");
|
||||
startButton?.setAttribute("aria-expanded", "false");
|
||||
syncDrawerToggleButton();
|
||||
}
|
||||
|
||||
function startConfiguredMatch(matchConfig) {
|
||||
if (matchConfig.names.length < 2) {
|
||||
matchForm.setStatus("참가자 닉네임을 2명 이상 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
appNode?.classList.add("match-live");
|
||||
openOptionsDrawer({ focus: false });
|
||||
arenaScene.startMatch(matchConfig);
|
||||
syncPauseButton();
|
||||
}
|
||||
|
||||
function setDrawerCollapsed(collapsed) {
|
||||
const nextCollapsed = Boolean(collapsed) && isMatchLive();
|
||||
|
||||
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
|
||||
drawer?.setAttribute("aria-hidden", "false");
|
||||
syncDrawerToggleButton();
|
||||
}
|
||||
|
||||
function syncDrawerToggleButton() {
|
||||
if (!drawerToggleButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
|
||||
drawerToggleButton.textContent = isCollapsed ? "옵션 펼치기" : "옵션 접기";
|
||||
drawerToggleButton.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
}
|
||||
|
||||
function syncPauseButton() {
|
||||
if (!pauseButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPaused = arenaScene.isMatchPaused();
|
||||
appNode?.classList.toggle("match-paused", isPaused);
|
||||
pauseButton.textContent = isPaused ? "계속" : "일시정지";
|
||||
pauseButton.setAttribute("aria-pressed", String(isPaused));
|
||||
}
|
||||
|
||||
function revealAppWhenStylesAreReady() {
|
||||
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
|
||||
const reveal = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove("app-booting");
|
||||
});
|
||||
};
|
||||
|
||||
if (!stylesheet || stylesheet.sheet) {
|
||||
reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
stylesheet.addEventListener("load", reveal, { once: true });
|
||||
}
|
||||
|
||||
startButton?.addEventListener("click", openOptionsDrawer);
|
||||
drawerCloseButton?.addEventListener("click", closeOptionsDrawer);
|
||||
drawerScrim?.addEventListener("click", closeOptionsDrawer);
|
||||
drawerToggleButton?.addEventListener("click", () => {
|
||||
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
|
||||
setDrawerCollapsed(!isCollapsed);
|
||||
});
|
||||
pauseButton?.addEventListener("click", () => {
|
||||
arenaScene.togglePause();
|
||||
syncPauseButton();
|
||||
});
|
||||
restartButton?.addEventListener("click", () => {
|
||||
startConfiguredMatch(matchForm.readMatchConfig());
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeOptionsDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: matchForm.readMatchConfig,
|
||||
setStatus: matchForm.setStatus,
|
||||
@@ -31,7 +145,11 @@ const game = new Phaser.Game({
|
||||
scene: arenaScene,
|
||||
});
|
||||
|
||||
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
|
||||
revealAppWhenStylesAreReady();
|
||||
|
||||
matchForm.onSubmit((matchConfig) => {
|
||||
startConfiguredMatch(matchConfig);
|
||||
});
|
||||
|
||||
const visitorCountNode = document.querySelector("#visitor-count");
|
||||
|
||||
|
||||
+1055
-142
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
export async function fetchTodayDeathStats() {
|
||||
const response = await fetch("/api/death-stats/today", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Death stats fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function addTodayDeathStats(deathStats) {
|
||||
const response = await fetch("/api/death-stats/today", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deathStats),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Death stats update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
+10
-1
@@ -3,7 +3,9 @@ import { NICKNAME_LENGTH } from "../constants.js";
|
||||
export function createMatchForm() {
|
||||
const form = getElement("#fighter-form");
|
||||
const namesInput = getElement("#player-names");
|
||||
const appNode = document.querySelector("#app");
|
||||
const statusNode = document.querySelector("#match-status");
|
||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeOutput = getElement("#team-size-value");
|
||||
|
||||
@@ -27,8 +29,15 @@ export function createMatchForm() {
|
||||
readMatchConfig,
|
||||
setStatus(message) {
|
||||
if (statusNode) {
|
||||
statusNode.textContent = message;
|
||||
statusNode.setAttribute("aria-hidden", "false");
|
||||
statusNode.title = message;
|
||||
}
|
||||
|
||||
statusTextNodes.forEach((node) => {
|
||||
node.textContent = message;
|
||||
});
|
||||
|
||||
appNode?.classList.add("status-active");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user