feat: Initial project setup

This commit is contained in:
2026-05-22 01:14:08 +09:00
commit 75f65e7918
165 changed files with 2740 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import Phaser from "phaser";
import { drawArena } from "./arenaRenderer.js";
import { clearCombatObjects, updateFighter } from "./combat.js";
import { ARENA_SIZE } from "./config.js";
import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js";
import { createFighter, syncFighterHud } from "./fighterFactory.js";
import { fighterManifest } from "./fighterManifest.js";
import { pickFighters } from "./fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
super("arena");
this.fighters = [];
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
this.ready = false;
this.setStatus = setStatus;
this.teams = [];
}
preload() {
preloadFighterSheets(this, fighterManifest);
}
create() {
this.physics.world.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.cameras.main.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.cameras.main.setBackgroundColor("#282819");
drawArena(this);
createFighterAnimations(this, fighterManifest);
this.ready = true;
this.startMatch(this.getInitialMatchConfig());
}
startMatch({ names = [], teamSize } = {}) {
if (!this.ready) {
return;
}
if (names.length < 2) {
this.setStatus("참가자 닉네임을 2명 이상 입력하세요.");
return;
}
const matchSetup = createMatchSetup(names, teamSize);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
this.matchId += 1;
this.matchOver = false;
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.destroy());
this.teams = matchSetup.teams;
this.fighters = matchSetup.fighters.map((fighterSetup, index) =>
createFighter(this, {
...fighterSetup,
skin: matchSkins[index],
}),
);
this.setStatus(matchStatusText(this.teams));
}
update(time) {
this.fighters.forEach(syncFighterHud);
if (this.matchOver) {
return;
}
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => this.finishMatch());
});
}
finishMatch() {
const livingTeams = new Set(
this.fighters.filter((fighter) => !fighter.isDead).map((fighter) => fighter.team.id),
);
if (livingTeams.size > 1) {
return;
}
const winningTeam = this.teams.find((team) => livingTeams.has(team.id));
this.matchOver = true;
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.body.setVelocity(0, 0));
this.setStatus(`${winningTeam?.label ?? "Draw"} 승리`);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "./config.js";
export function drawArena(scene) {
const graphics = scene.add.graphics();
graphics.fillStyle(0x34351f, 1);
graphics.fillRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.fillStyle(0x556235, 0.12);
for (let row = 0; row < GRID_SIZE; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) {
if ((row + column) % 2 === 0) {
graphics.fillRect(column * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}
graphics.lineStyle(1, 0xd3bd72, 0.11);
for (let index = 0; index <= GRID_SIZE; index += 1) {
const offset = index * TILE_SIZE;
graphics.lineBetween(offset, 0, offset, ARENA_SIZE);
graphics.lineBetween(0, offset, ARENA_SIZE, offset);
}
graphics.lineStyle(12, 0x17180e, 1);
graphics.strokeRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.lineStyle(2, 0xd3bd72, 0.35);
graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24);
}
+356
View File
@@ -0,0 +1,356 @@
import Phaser from "phaser";
import {
ATTACK_COOLDOWN,
ATTACK_RANGE,
FIGHTER_SCALE,
MELEE_CRITICAL_CHANCE,
MOVE_SPEED,
PROJECTILE_LIFETIME,
PROJECTILE_SPEED,
RANGED_CRITICAL_CHANCE,
RANGED_ATTACK_RANGE,
} from "./config.js";
import {
getAttackSpeedMultiplier,
getMovementSpeedMultiplier,
} from "./combatSettings.js";
import {
fighterAnimationKey,
fighterAttackEffectAnimationKey,
fighterAttackEffectKey,
fighterProjectileKey,
} from "./fighterAssets.js";
const MELEE_HIT_DELAY = 260;
const PROJECTILE_FIRE_DELAY = 360;
const PROJECTILE_HIT_RADIUS = 8;
const SPELL_CAST_DELAY = 340;
const SPELL_HIT_DELAY = 160;
export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter);
if (!enemy || fighter.isDead || enemy.isDead || fighter.isLocked) {
fighter.body.setVelocity(0, 0);
return;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, enemy.x, enemy.y);
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier());
playIfNeeded(fighter, "walk");
return;
}
fighter.body.setVelocity(0, 0);
if (time >= fighter.nextAttackAt) {
beginAttack(scene, fighter, enemy, time, onWinner);
return;
}
playIfNeeded(fighter, "idle");
}
export function clearCombatObjects(scene) {
scene.combatObjects?.forEach((object) => {
object.cleanup?.();
object.destroy();
});
scene.combatObjects?.clear();
}
function beginAttack(scene, attacker, defender, time, onWinner) {
const attack = createAttackProfile(attacker);
attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN);
attacker.isLocked = true;
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
switch (getCombatType(attacker)) {
case "projectile":
queueProjectile(scene, attacker, defender, onWinner);
return;
case "instant-spell":
queueInstantSpell(scene, attacker, defender, onWinner);
return;
default:
queueMeleeHit(scene, attacker, defender, onWinner, attack);
}
}
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY), () => {
applyHit(scene, attacker, defender, onWinner, matchId, {
instantKill: attack.isCritical,
});
});
}
function queueProjectile(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnProjectile(scene, attacker, defender, onWinner, matchId);
});
}
function queueInstantSpell(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnSpellEffect(scene, attacker, defender, onWinner, matchId);
});
}
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
const direction = defender.x < attacker.x ? -1 : 1;
const projectile = scene.physics.add.image(
attacker.x + direction * 42,
attacker.y + 4,
fighterProjectileKey(attacker.skin),
);
projectile.setDepth(3);
projectile.setScale(2);
projectile.body.setCircle(PROJECTILE_HIT_RADIUS, 8, 8);
projectile.setRotation(Phaser.Math.Angle.Between(projectile.x, projectile.y, defender.x, defender.y));
scene.physics.moveToObject(
projectile,
defender,
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
);
trackCombatObject(scene, projectile);
projectile.lastHitCheckX = projectile.x;
projectile.lastHitCheckY = projectile.y;
const hitDefender = () => {
if (projectile.hasHit) {
return;
}
if (!isAttackValid(scene, attacker, defender, matchId)) {
disposeCombatObject(scene, projectile);
return;
}
projectile.hasHit = true;
disposeCombatObject(scene, projectile);
applyHit(scene, attacker, defender, onWinner, matchId);
};
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
const checkProjectilePath = () => {
if (!projectile.active || projectile.hasHit) {
return;
}
if (!isAttackValid(scene, attacker, defender, matchId)) {
disposeCombatObject(scene, projectile);
return;
}
if (projectilePathHitsDefender(projectile, defender)) {
hitDefender();
return;
}
projectile.lastHitCheckX = projectile.x;
projectile.lastHitCheckY = projectile.y;
};
scene.events.on(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
projectile.cleanup = () => {
overlap.destroy();
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
};
scene.time.delayedCall(PROJECTILE_LIFETIME, () => {
disposeCombatObject(scene, projectile);
});
}
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER_SCALE);
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
disposeCombatObject(scene, effect);
});
scene.time.delayedCall(scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY), () => {
applyHit(scene, attacker, defender, onWinner, matchId);
});
}
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
defender.hp = instantKill ? 0 : Math.max(0, defender.hp - Phaser.Math.Between(14, 24));
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
killFighter(defender, attacker, onWinner);
return;
}
defender.isLocked = true;
playAnimation(defender, "hurt");
scene.cameras.main.shake(90, 0.002);
}
function getAttackRange(fighter) {
if (getCombatType(fighter) === "melee") {
return ATTACK_RANGE;
}
return fighter.skin.combat?.range ?? RANGED_ATTACK_RANGE;
}
function getCombatType(fighter) {
return fighter.skin.combat?.type ?? "melee";
}
function createAttackProfile(attacker) {
const isCritical = Math.random() < getCriticalChance(attacker);
return {
animation:
isCritical && attacker.skin.animations.attack03 ? "attack03" : "attack",
isCritical,
};
}
function getCriticalChance(fighter) {
if (getCombatType(fighter) !== "melee") {
return fighter.skin.combat?.criticalChance ?? RANGED_CRITICAL_CHANCE;
}
return fighter.skin.combat?.criticalChance ?? MELEE_CRITICAL_CHANCE;
}
function isAttackValid(scene, attacker, defender, matchId) {
return (
!scene.matchOver &&
matchId === scene.matchId &&
attacker.active &&
defender.active &&
!attacker.isDead &&
!defender.isDead
);
}
function projectilePathHitsDefender(projectile, defender) {
if (!defender.body) {
return false;
}
const projectilePath = new Phaser.Geom.Line(
projectile.lastHitCheckX,
projectile.lastHitCheckY,
projectile.x,
projectile.y,
);
const defenderHitArea = new Phaser.Geom.Rectangle(
defender.body.x - PROJECTILE_HIT_RADIUS,
defender.body.y - PROJECTILE_HIT_RADIUS,
defender.body.width + PROJECTILE_HIT_RADIUS * 2,
defender.body.height + PROJECTILE_HIT_RADIUS * 2,
);
return (
Phaser.Geom.Rectangle.Contains(defenderHitArea, projectile.x, projectile.y) ||
Phaser.Geom.Intersects.LineToRectangle(projectilePath, defenderHitArea)
);
}
function killFighter(defender, winner, onWinner) {
defender.isDead = true;
defender.isLocked = true;
defender.body.setVelocity(0, 0);
defender.body.enable = false;
defender.healthBar.width = 0;
playAnimation(defender, "death");
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
onWinner(winner);
}
function findNearestEnemy(fighters, fighter) {
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((candidate) => {
if (
candidate === fighter ||
candidate.isDead ||
candidate.team.id === fighter.team.id
) {
return;
}
const distance = Phaser.Math.Distance.Between(
fighter.x,
fighter.y,
candidate.x,
candidate.y,
);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestEnemy = candidate;
}
});
return nearestEnemy;
}
function playIfNeeded(fighter, action) {
const key = fighterAnimationKey(fighter.skin, action);
if (fighter.anims.currentAnim?.key !== key) {
playAnimation(fighter, action);
}
}
function playAnimation(fighter, action, timeScale = 1) {
fighter.anims.timeScale = timeScale;
fighter.play(fighterAnimationKey(fighter.skin, action), true);
}
function scaledAttackDelay(duration) {
return duration / getAttackSpeedMultiplier();
}
function trackCombatObject(scene, object) {
scene.combatObjects ??= new Set();
scene.combatObjects.add(object);
}
function disposeCombatObject(scene, object) {
if (!object?.active) {
return;
}
object.cleanup?.();
scene.combatObjects?.delete(object);
object.destroy();
}
+32
View File
@@ -0,0 +1,32 @@
const combatSpeed = {
attack: 1,
movement: 1,
};
export function getAttackSpeedMultiplier() {
return combatSpeed.attack;
}
export function getMovementSpeedMultiplier() {
return combatSpeed.movement;
}
export function setCombatSpeedMultipliers({ attack, movement }) {
if (attack !== undefined) {
combatSpeed.attack = validMultiplier(attack);
}
if (movement !== undefined) {
combatSpeed.movement = validMultiplier(movement);
}
}
function validMultiplier(value) {
const multiplier = Number(value);
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new Error(`Invalid speed multiplier: ${value}`);
}
return multiplier;
}
+26
View File
@@ -0,0 +1,26 @@
export const GRID_SIZE = 16;
export const TILE_SIZE = 64;
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
export const ATTACK_RANGE = 84;
export const ATTACK_COOLDOWN = 840;
export const DEFAULT_TEAM_SIZE = 5;
export const FIGHTER_SCALE = 3;
export const MAX_TEAM_SIZE = 100;
export const MELEE_CRITICAL_CHANCE = 0.05;
export const MOVE_SPEED = 148;
export const PROJECTILE_LIFETIME = 1800;
export const PROJECTILE_SPEED = 420;
export const RANGED_CRITICAL_CHANCE = 0;
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
export const TEAM_COLORS = [
"#da6a48",
"#5fb4d9",
"#9bd15a",
"#d6a94a",
"#d477b8",
"#7f90e8",
"#63c5a6",
"#d98755",
];
+113
View File
@@ -0,0 +1,113 @@
const animationOptions = {
attack: { frameRate: 15, repeat: 0 },
attack02: { frameRate: 15, repeat: 0 },
attack03: { frameRate: 15, repeat: 0 },
block: { frameRate: 13, repeat: 0 },
death: { frameRate: 11, repeat: 0 },
heal: { frameRate: 13, repeat: 0 },
hurt: { frameRate: 13, repeat: 0 },
idle: { frameRate: 7, repeat: -1 },
walk: { frameRate: 10, repeat: -1 },
walk02: { frameRate: 10, repeat: -1 },
};
export function fighterSheetKey(skin, action) {
return `${skin.key}-${action}`;
}
export function fighterAnimationKey(skin, action) {
return `${fighterSheetKey(skin, action)}-anim`;
}
export function fighterAttackEffectKey(skin) {
return `${skin.key}-attack-effect`;
}
export function fighterAttackEffectAnimationKey(skin) {
return `${fighterAttackEffectKey(skin)}-anim`;
}
export function fighterProjectileKey(skin) {
return `${skin.key}-projectile`;
}
export function preloadFighterSheets(scene, skins) {
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
scene.load.spritesheet(
fighterSheetKey(skin, action),
`${skin.assetRoot}/${animation.file}`,
{ frameWidth: 100, frameHeight: 100 },
);
});
preloadCombatAssets(scene, skin);
});
}
export function createFighterAnimations(scene, skins) {
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
const key = fighterAnimationKey(skin, action);
if (scene.anims.exists(key)) {
return;
}
const { frameRate, repeat } = animationOptions[action];
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), {
start: 0,
end: animation.frames - 1,
}),
frameRate,
repeat,
});
});
createAttackEffectAnimation(scene, skin);
});
}
function preloadCombatAssets(scene, skin) {
const projectile = skin.combat?.projectile;
const attackEffect = skin.combat?.attackEffect;
if (projectile) {
scene.load.image(fighterProjectileKey(skin), `${skin.assetRoot}/${projectile.file}`);
}
if (attackEffect) {
scene.load.spritesheet(
fighterAttackEffectKey(skin),
`${skin.assetRoot}/${attackEffect.file}`,
{ frameWidth: 100, frameHeight: 100 },
);
}
}
function createAttackEffectAnimation(scene, skin) {
const attackEffect = skin.combat?.attackEffect;
if (!attackEffect) {
return;
}
const key = fighterAttackEffectAnimationKey(skin);
if (scene.anims.exists(key)) {
return;
}
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(fighterAttackEffectKey(skin), {
start: 0,
end: attackEffect.frames - 1,
}),
frameRate: attackEffect.frameRate ?? 14,
repeat: 0,
});
}
+73
View File
@@ -0,0 +1,73 @@
import Phaser from "phaser";
import { FIGHTER_SCALE } from "./config.js";
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
fighter.setScale(FIGHTER_SCALE);
fighter.setDepth(2);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
fighter.body.setSize(22, 20);
fighter.body.setOffset(39, 60);
fighter.nameLabel = scene.add
.text(x, y - 68, name, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: team.color,
strokeThickness: 4,
})
.setOrigin(0.5)
.setDepth(4);
fighter.healthBack = scene.add
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
.setDepth(4);
fighter.healthBar = scene.add
.rectangle(x - 34, y - 44, 68, 4, 0xd95f3f, 1)
.setOrigin(0, 0.5)
.setDepth(5);
fighter.skin = skin;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.hp = 100;
fighter.nextAttackAt = 0;
fighter.isLocked = false;
fighter.isDead = false;
fighter.play(fighterAnimationKey(skin, "walk"));
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
if (fighter.isDead) {
return;
}
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
fighter.isLocked = false;
}
});
attachHudCleanup(fighter);
return fighter;
}
export function syncFighterHud(fighter) {
fighter.nameLabel.setPosition(fighter.x, fighter.y - 68);
fighter.healthBack.setPosition(fighter.x, fighter.y - 44);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - 44);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / 100));
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();
originalDestroy(...args);
};
}
+327
View File
@@ -0,0 +1,327 @@
const animation = (file, frames) => ({ file, frames });
export const fighterManifest = [
{
key: "knight",
label: "Knight",
assetRoot: "assets/characters/knight",
animations: {
idle: animation("Knight-Idle.png", 6),
walk: animation("Knight-Walk.png", 8),
attack: animation("Knight-Attack01.png", 7),
attack02: animation("Knight-Attack02.png", 10),
attack03: animation("Knight-Attack03.png", 11),
block: animation("Knight-Block.png", 4),
hurt: animation("Knight-Hurt.png", 4),
death: animation("Knight-Death.png", 4),
},
},
{
key: "orc",
label: "Orc",
assetRoot: "assets/characters/orc",
animations: {
idle: animation("Orc-Idle.png", 6),
walk: animation("Orc-Walk.png", 8),
attack: animation("Orc-Attack01.png", 6),
attack02: animation("Orc-Attack02.png", 6),
hurt: animation("Orc-Hurt.png", 4),
death: animation("Orc-Death.png", 4),
},
},
{
key: "archer",
label: "Archer",
assetRoot: "assets/characters/archer",
combat: {
projectile: {
file: "projectiles/Arrow02(32x32).png",
},
type: "projectile",
},
animations: {
idle: animation("Archer-Idle.png", 6),
walk: animation("Archer-Walk.png", 8),
attack: animation("Archer-Attack01.png", 9),
attack02: animation("Archer-Attack02.png", 12),
hurt: animation("Archer-Hurt.png", 4),
death: animation("Archer-Death.png", 4),
},
},
{
key: "armored-axeman",
label: "Armored Axeman",
assetRoot: "assets/characters/armored-axeman",
animations: {
idle: animation("Armored Axeman-Idle.png", 6),
walk: animation("Armored Axeman-Walk.png", 8),
attack: animation("Armored Axeman-Attack01.png", 9),
attack02: animation("Armored Axeman-Attack02.png", 9),
attack03: animation("Armored Axeman-Attack03.png", 12),
hurt: animation("Armored Axeman-Hurt.png", 4),
death: animation("Armored Axeman-Death.png", 4),
},
},
{
key: "armored-orc",
label: "Armored Orc",
assetRoot: "assets/characters/armored-orc",
animations: {
idle: animation("Armored Orc-Idle.png", 6),
walk: animation("Armored Orc-Walk.png", 8),
attack: animation("Armored Orc-Attack01.png", 7),
attack02: animation("Armored Orc-Attack02.png", 8),
attack03: animation("Armored Orc-Attack03.png", 9),
block: animation("Armored Orc-Block.png", 4),
hurt: animation("Armored Orc-Hurt.png", 4),
death: animation("Armored Orc-Death.png", 4),
},
},
{
key: "armored-skeleton",
label: "Armored Skeleton",
assetRoot: "assets/characters/armored-skeleton",
animations: {
idle: animation("Armored Skeleton-Idle.png", 6),
walk: animation("Armored Skeleton-Walk.png", 8),
attack: animation("Armored Skeleton-Attack01.png", 8),
attack02: animation("Armored Skeleton-Attack02.png", 9),
hurt: animation("Armored Skeleton-Hurt.png", 4),
death: animation("Armored Skeleton-Death.png", 4),
},
},
{
key: "elite-orc",
label: "Elite Orc",
assetRoot: "assets/characters/elite-orc",
animations: {
idle: animation("Elite Orc-Idle.png", 6),
walk: animation("Elite Orc-Walk.png", 8),
attack: animation("Elite Orc-Attack01.png", 7),
attack02: animation("Elite Orc-Attack02.png", 11),
attack03: animation("Elite Orc-Attack03.png", 9),
hurt: animation("Elite Orc-Hurt.png", 4),
death: animation("Elite Orc-Death.png", 4),
},
},
{
key: "greatsword-skeleton",
label: "Greatsword Skeleton",
assetRoot: "assets/characters/greatsword-skeleton",
animations: {
idle: animation("Greatsword Skeleton-Idle.png", 6),
walk: animation("Greatsword Skeleton-Walk.png", 9),
attack: animation("Greatsword Skeleton-Attack01.png", 9),
attack02: animation("Greatsword Skeleton-Attack02.png", 12),
attack03: animation("Greatsword Skeleton-Attack03.png", 8),
hurt: animation("Greatsword Skeleton-Hurt.png", 4),
death: animation("Greatsword Skeleton-Death.png", 4),
},
},
{
key: "knight-templar",
label: "Knight Templar",
assetRoot: "assets/characters/knight-templar",
animations: {
idle: animation("Knight Templar-Idle.png", 6),
walk: animation("Knight Templar-Walk01.png", 8),
walk02: animation("Knight Templar-Walk02.png", 8),
attack: animation("Knight Templar-Attack01.png", 7),
attack02: animation("Knight Templar-Attack02.png", 8),
attack03: animation("Knight Templar-Attack03.png", 11),
block: animation("Knight Templar-Block.png", 4),
hurt: animation("Knight Templar-Hurt.png", 4),
death: animation("Knight Templar-Death.png", 4),
},
},
{
key: "lancer",
label: "Lancer",
assetRoot: "assets/characters/lancer",
animations: {
idle: animation("Lancer-Idle.png", 6),
walk: animation("Lancer-Walk01.png", 8),
walk02: animation("Lancer-Walk02.png", 8),
attack: animation("Lancer-Attack01.png", 6),
attack02: animation("Lancer-Attack02.png", 9),
attack03: animation("Lancer-Attack03.png", 8),
hurt: animation("Lancer-Hurt.png", 4),
death: animation("Lancer-Death.png", 4),
},
},
{
key: "orc-rider",
label: "Orc rider",
assetRoot: "assets/characters/orc-rider",
animations: {
idle: animation("Orc rider-Idle.png", 6),
walk: animation("Orc rider-Walk.png", 8),
attack: animation("Orc rider-Attack01.png", 8),
attack02: animation("Orc rider-Attack02.png", 9),
attack03: animation("Orc rider-Attack03.png", 11),
block: animation("Orc rider-Block.png", 4),
hurt: animation("Orc rider-Hurt.png", 4),
death: animation("Orc rider-Death.png", 4),
},
},
{
key: "priest",
label: "Priest",
assetRoot: "assets/characters/priest",
combat: {
attackEffect: {
file: "effects/Priest-Attack_Effect.png",
frames: 5,
},
type: "instant-spell",
},
animations: {
idle: animation("Priest-Idle.png", 6),
walk: animation("Priest-Walk.png", 8),
attack: animation("Priest-Attack.png", 9),
heal: animation("Priest-Heal.png", 6),
hurt: animation("Priest-Hurt.png", 4),
death: animation("Priest-Death.png", 4),
},
},
{
key: "skeleton",
label: "Skeleton",
assetRoot: "assets/characters/skeleton",
animations: {
idle: animation("Skeleton-Idle.png", 6),
walk: animation("Skeleton-Walk.png", 8),
attack: animation("Skeleton-Attack01.png", 6),
attack02: animation("Skeleton-Attack02.png", 7),
block: animation("Skeleton-Block.png", 4),
hurt: animation("Skeleton-Hurt.png", 4),
death: animation("Skeleton-Death.png", 4),
},
},
{
key: "skeleton-archer",
label: "Skeleton Archer",
assetRoot: "assets/characters/skeleton-archer",
combat: {
projectile: {
file: "projectiles/Arrow03(32x32).png",
},
type: "projectile",
},
animations: {
idle: animation("Skeleton Archer-Idle.png", 6),
walk: animation("Skeleton Archer-Walk.png", 8),
attack: animation("Skeleton Archer-Attack.png", 9),
hurt: animation("Skeleton Archer-Hurt.png", 4),
death: animation("Skeleton Archer-Death.png", 4),
},
},
{
key: "slime",
label: "Slime",
assetRoot: "assets/characters/slime",
animations: {
idle: animation("Slime-Idle.png", 6),
walk: animation("Slime-Walk.png", 6),
attack: animation("Slime-Attack01.png", 6),
attack02: animation("Slime-Attack02.png", 12),
hurt: animation("Slime-Hurt.png", 4),
death: animation("Slime-Death.png", 4),
},
},
{
key: "soldier-close",
label: "Soldier Close",
assetRoot: "assets/characters/soldier",
combat: {
type: "melee",
},
animations: {
idle: animation("Soldier-Idle.png", 6),
walk: animation("Soldier-Walk.png", 8),
attack: animation("Soldier-Attack01.png", 6),
attack02: animation("Soldier-Attack02.png", 6),
hurt: animation("Soldier-Hurt.png", 4),
death: animation("Soldier-Death.png", 4),
},
},
{
key: "soldier-range",
label: "Soldier Range",
assetRoot: "assets/characters/soldier",
combat: {
projectile: {
file: "projectiles/Arrow01(32x32).png",
},
type: "projectile",
},
animations: {
idle: animation("Soldier-Idle.png", 6),
walk: animation("Soldier-Walk.png", 8),
attack: animation("Soldier-Attack03.png", 9),
hurt: animation("Soldier-Hurt.png", 4),
death: animation("Soldier-Death.png", 4),
},
},
{
key: "swordsman",
label: "Swordsman",
assetRoot: "assets/characters/swordsman",
animations: {
idle: animation("Swordsman-Idle.png", 6),
walk: animation("Swordsman-Walk.png", 8),
attack: animation("Swordsman-Attack01.png", 7),
attack02: animation("Swordsman-Attack02.png", 15),
attack03: animation("Swordsman-Attack3.png", 12),
hurt: animation("Swordsman-Hurt.png", 5),
death: animation("Swordsman-Death.png", 4),
},
},
{
key: "werebear",
label: "Werebear",
assetRoot: "assets/characters/werebear",
animations: {
idle: animation("Werebear-Idle.png", 6),
walk: animation("Werebear-Walk.png", 8),
attack: animation("Werebear-Attack01.png", 9),
attack02: animation("Werebear-Attack02.png", 13),
attack03: animation("Werebear-Attack03.png", 9),
hurt: animation("Werebear-Hurt.png", 4),
death: animation("Werebear-Death.png", 4),
},
},
{
key: "werewolf",
label: "Werewolf",
assetRoot: "assets/characters/werewolf",
animations: {
idle: animation("Werewolf-Idle.png", 6),
walk: animation("Werewolf-Walk.png", 8),
attack: animation("Werewolf-Attack01.png", 9),
attack02: animation("Werewolf-Attack02.png", 13),
hurt: animation("Werewolf-Hurt.png", 4),
death: animation("Werewolf-Death.png", 4),
},
},
{
key: "wizard",
label: "Wizard",
assetRoot: "assets/characters/wizard",
combat: {
attackEffect: {
file: "effects/Wizard-Attack01_Effect.png",
frames: 10,
},
type: "instant-spell",
},
animations: {
idle: animation("Wizard-Idle.png", 6),
walk: animation("Wizard-Walk.png", 8),
attack: animation("Wizard-Attack01.png", 6),
attack02: animation("Wizard-Attack02.png", 6),
hurt: animation("Wizard-Hurt.png", 4),
death: animation("Wizard-DEATH.png", 4),
},
},
];
+32
View File
@@ -0,0 +1,32 @@
export function pickUniqueFighters(fighters, count) {
if (count > fighters.length) {
throw new Error(`Cannot pick ${count} fighters from ${fighters.length} entries.`);
}
return shuffleFighters(fighters).slice(0, count);
}
export function pickFighters(fighters, count) {
if (fighters.length === 0) {
return [];
}
const picks = [];
while (picks.length < count) {
picks.push(...shuffleFighters(fighters).slice(0, count - picks.length));
}
return picks;
}
function shuffleFighters(fighters) {
const pool = [...fighters];
for (let index = pool.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[pool[index], pool[randomIndex]] = [pool[randomIndex], pool[index]];
}
return pool;
}
+114
View File
@@ -0,0 +1,114 @@
import {
ARENA_SIZE,
DEFAULT_TEAM_SIZE,
GRID_SIZE,
MAX_TEAM_SIZE,
TEAM_COLORS,
TILE_SIZE,
} from "./config.js";
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
const shuffledNames = shuffle([...names]);
const teamSize = resolveTeamSize(shuffledNames.length, requestedTeamSize);
const teams = createTeams(shuffledNames.length, teamSize);
const spawns = createRandomSpawnPoints(shuffledNames.length);
return {
fighters: shuffledNames.map((name, index) => {
const teamSlot = Math.floor(index / teamSize);
return {
...spawns[index],
name,
team: teams[teamSlot],
teamIndex: index - teamSlot * teamSize,
};
}),
teams,
};
}
export function matchStatusText(teams) {
if (teams.length > 8) {
const playerCount = teams.reduce((count, team) => count + team.size, 0);
return `${teams.length}팀 전투: 참가자 ${playerCount}`;
}
return `${teams.length}팀 전투: ${teams.map((team) => team.size).join(" vs ")}`;
}
function createTeams(playerCount, teamSize) {
return Array.from({ length: Math.ceil(playerCount / teamSize) }, (_, index) => ({
color: TEAM_COLORS[index % TEAM_COLORS.length],
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
}));
}
function createRandomSpawnPoints(count) {
const spawnSlots = [];
for (let row = 1; row < GRID_SIZE - 1; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) {
spawnSlots.push({
x: column * TILE_SIZE + TILE_SIZE / 2,
y: row * TILE_SIZE + TILE_SIZE / 2,
});
}
}
const points = [];
while (points.length < count) {
shuffle([...spawnSlots]).forEach((slot) => {
if (points.length >= count) {
return;
}
points.push({
faceLeft: Math.random() >= 0.5,
x: clampInsideArena(slot.x + spawnJitter(), TILE_SIZE / 2),
y: clampInsideArena(slot.y + spawnJitter(), TILE_SIZE),
});
});
}
return points;
}
function resolveTeamSize(playerCount, requestedTeamSize) {
const teamSize = clamp(
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
1,
MAX_TEAM_SIZE,
);
if (playerCount <= teamSize) {
return Math.max(1, Math.ceil(playerCount / 2));
}
return teamSize;
}
function spawnJitter() {
return (Math.random() - 0.5) * TILE_SIZE * 0.36;
}
function clampInsideArena(value, margin) {
return clamp(value, margin, ARENA_SIZE - margin);
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function shuffle(items) {
for (let index = items.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[items[index], items[randomIndex]] = [items[randomIndex], items[index]];
}
return items;
}