feat: Initial project setup
This commit is contained in:
@@ -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"} 승리`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import Phaser from "phaser";
|
||||
import { ArenaScene } from "./game/ArenaScene.js";
|
||||
import { ARENA_SIZE } from "./game/config.js";
|
||||
import { createMatchForm } from "./ui/matchForm.js";
|
||||
import "./styles.css";
|
||||
|
||||
const matchForm = createMatchForm();
|
||||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: matchForm.readMatchConfig,
|
||||
setStatus: matchForm.setStatus,
|
||||
});
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: "game",
|
||||
width: ARENA_SIZE,
|
||||
height: ARENA_SIZE,
|
||||
pixelArt: true,
|
||||
backgroundColor: "#282819",
|
||||
physics: {
|
||||
default: "arcade",
|
||||
arcade: {
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
scene: arenaScene,
|
||||
});
|
||||
|
||||
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
|
||||
|
||||
window.arenaGame = game;
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #141612;
|
||||
color: #f6f1dd;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
background:
|
||||
linear-gradient(135deg, rgb(112 53 29 / 0.16), transparent 30%),
|
||||
linear-gradient(180deg, #171912, #0d0f0c);
|
||||
}
|
||||
|
||||
.fighter-entry {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 28px;
|
||||
padding: clamp(24px, 5vw, 48px);
|
||||
border-right: 1px solid rgb(230 207 134 / 0.14);
|
||||
}
|
||||
|
||||
.entry-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #d6a94a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(230 207 134 / 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 6px;
|
||||
color: #d6a94a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.team-size-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
output {
|
||||
min-width: 88px;
|
||||
border: 1px solid rgb(230 207 134 / 0.18);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #1d2017;
|
||||
color: #fff7df;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #e6d7ac;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
input:not([type="range"]),
|
||||
textarea {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgb(230 207 134 / 0.24);
|
||||
border-radius: 6px;
|
||||
padding: 0 14px;
|
||||
background: #26291d;
|
||||
color: #fff7df;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 232px;
|
||||
resize: vertical;
|
||||
padding-block: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #d6a94a;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: #d6a94a;
|
||||
box-shadow: 0 0 0 3px rgb(214 169 74 / 0.18);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 50px;
|
||||
border-radius: 6px;
|
||||
background: #c84f34;
|
||||
color: #fff1da;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #dd6245;
|
||||
}
|
||||
|
||||
.arena-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: clamp(16px, 3vw, 36px);
|
||||
}
|
||||
|
||||
#game {
|
||||
width: min(100%, calc(100vh - 72px), 1080px);
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(245 219 136 / 0.22);
|
||||
border-radius: 8px;
|
||||
background: #242617;
|
||||
box-shadow:
|
||||
0 24px 80px rgb(0 0 0 / 0.45),
|
||||
inset 0 0 0 1px rgb(255 244 205 / 0.06);
|
||||
}
|
||||
|
||||
#game canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.match-status {
|
||||
position: absolute;
|
||||
top: clamp(24px, 4vw, 48px);
|
||||
left: 50%;
|
||||
min-width: min(78vw, 340px);
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid rgb(252 224 147 / 0.22);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgb(18 19 13 / 0.82);
|
||||
color: #f8e8b5;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
#app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fighter-entry {
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgb(230 207 134 / 0.14);
|
||||
}
|
||||
|
||||
.arena-shell {
|
||||
align-content: start;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
#game {
|
||||
width: min(100%, calc(100svh - 360px));
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.match-status {
|
||||
top: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const nicknameLength = 18;
|
||||
|
||||
export function createMatchForm() {
|
||||
const form = getElement("#fighter-form");
|
||||
const namesInput = getElement("#player-names");
|
||||
const statusNode = getElement("#match-status");
|
||||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeOutput = getElement("#team-size-value");
|
||||
|
||||
const readMatchConfig = () => ({
|
||||
names: nicknameValues(namesInput.value),
|
||||
teamSize: Number(teamSizeInput.value),
|
||||
});
|
||||
|
||||
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
|
||||
teamSizeInput.addEventListener("input", () => {
|
||||
syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
|
||||
});
|
||||
|
||||
return {
|
||||
onSubmit(handler) {
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
handler(readMatchConfig());
|
||||
});
|
||||
},
|
||||
readMatchConfig,
|
||||
setStatus(message) {
|
||||
statusNode.textContent = message;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getElement(selector) {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
if (!element) {
|
||||
throw new Error(`Missing required element: ${selector}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function nicknameValues(value) {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map((name) => name.trim().slice(0, nicknameLength))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function syncTeamSizeOutput(input, output) {
|
||||
output.textContent = `${input.value} vs ${input.value}`;
|
||||
}
|
||||
Reference in New Issue
Block a user