Add visitor tracking and combat selection features
This commit is contained in:
+87
-1
@@ -1,57 +1,143 @@
|
||||
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
|
||||
export const GRID_SIZE = 50;
|
||||
// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다.
|
||||
export const TILE_SIZE = 64;
|
||||
// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다.
|
||||
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||
|
||||
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||
export const ATTACK_RANGE = 84;
|
||||
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
|
||||
export const ATTACK_COOLDOWN = 840;
|
||||
// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다.
|
||||
export const ATTACK_DAMAGE_MIN = 14;
|
||||
// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다.
|
||||
export const ATTACK_DAMAGE_MAX = 24;
|
||||
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
|
||||
export const DEFAULT_TEAM_SIZE = 5;
|
||||
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
||||
export const FIGHTER_SCALE = 3;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
|
||||
export const FIGHTER_FRAME_WIDTH = 100;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
|
||||
export const FIGHTER_FRAME_HEIGHT = 100;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
|
||||
export const FIGHTER_HITBOX_WIDTH = 22;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
|
||||
export const FIGHTER_HITBOX_HEIGHT = 20;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_X = 39;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_Y = 40;
|
||||
// 캐릭터의 기본 최대 체력입니다.
|
||||
export const FIGHTER_MAX_HP = 100;
|
||||
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
|
||||
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
|
||||
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
|
||||
export const KILL_GROWTH_MULTIPLIER = 1.25;
|
||||
// 처치 성장 연출 tween 지속 시간(ms)입니다.
|
||||
export const KILL_GROWTH_TWEEN_DURATION = 180;
|
||||
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
|
||||
export const MAX_TEAM_SIZE = 100;
|
||||
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
|
||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
||||
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
|
||||
export const MOVE_SPEED = 148;
|
||||
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
|
||||
export const PROJECTILE_LIFETIME = 1800;
|
||||
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
|
||||
export const PROJECTILE_SPEED = 420;
|
||||
// 원거리 캐릭터의 기본 치명타 확률입니다.
|
||||
export const RANGED_CRITICAL_CHANCE = 0;
|
||||
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
||||
|
||||
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const MELEE_HIT_DELAY = 260;
|
||||
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
|
||||
export const PROJECTILE_FIRE_DELAY = 360;
|
||||
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
|
||||
export const PROJECTILE_BODY_OFFSET = 4;
|
||||
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
|
||||
export const PROJECTILE_HIT_PADDING = 20;
|
||||
// 투사체 충돌 원형 바디의 반지름입니다.
|
||||
export const PROJECTILE_HIT_RADIUS = 12;
|
||||
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
|
||||
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
||||
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
|
||||
export const SPELL_CAST_DELAY = 340;
|
||||
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const SPELL_HIT_DELAY = 160;
|
||||
|
||||
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
|
||||
export const CAMERA_MIN_ZOOM = 1;
|
||||
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
|
||||
export const CAMERA_MAX_ZOOM = 3;
|
||||
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
|
||||
export const CAMERA_ZOOM_STEP = 0.1;
|
||||
// 미니맵 카메라가 보일 때의 투명도입니다.
|
||||
export const MINIMAP_ALPHA = 0.8;
|
||||
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
|
||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
||||
// 미니맵의 고정 픽셀 크기입니다.
|
||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
||||
// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
|
||||
// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
||||
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
||||
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
||||
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
|
||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 10;
|
||||
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
|
||||
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_LATE_FIGHT_ZOOM = 2;
|
||||
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
|
||||
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
|
||||
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
|
||||
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
|
||||
// 선택 실루엣의 빨간색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
|
||||
// 선택 실루엣의 초록색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
|
||||
// 선택 실루엣의 파란색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
|
||||
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
|
||||
|
||||
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
|
||||
export const NICKNAME_LENGTH = 18;
|
||||
|
||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
|
||||
export const FIGHTER_ANIMATION_OPTIONS = {
|
||||
// 기본 공격 애니메이션 속도입니다.
|
||||
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 },
|
||||
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk02: { frameRate: 10, repeat: -1 },
|
||||
};
|
||||
|
||||
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
|
||||
export const TEAM_COLORS = [
|
||||
"#da6a48",
|
||||
"#5fb4d9",
|
||||
|
||||
+77
-8
@@ -9,6 +9,7 @@ import {
|
||||
MINIMAP_VIEWPORT_SIZE,
|
||||
MINIMAP_VIEW_FRAME_OUTLINE,
|
||||
MINIMAP_VIEW_FRAME_STROKE,
|
||||
SELECTED_FIGHTER_CAMERA_ZOOM,
|
||||
SPECTATOR_CAMERA_LERP,
|
||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
||||
SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||
@@ -45,6 +46,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
}
|
||||
};
|
||||
this.observedCombat = [];
|
||||
this.selectedFighter = null;
|
||||
this.teams = [];
|
||||
}
|
||||
|
||||
@@ -70,7 +72,6 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.cameras.main.ignore(this.minimapViewportFrame);
|
||||
this.updateMinimapViewportFrame();
|
||||
this.minimapCamera.setAlpha(0); // 기본적으로는 숨김
|
||||
|
||||
// 마우스 휠로 줌 조절
|
||||
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
|
||||
const newZoom = Phaser.Math.Clamp(
|
||||
@@ -83,6 +84,19 @@ export class ArenaScene extends Phaser.Scene {
|
||||
|
||||
// 확대 시 미니맵 표시
|
||||
});
|
||||
this.input.on("gameobjectdown", (pointer, gameObject) => {
|
||||
if (this.fighters.includes(gameObject)) {
|
||||
this.selectFighter(gameObject);
|
||||
}
|
||||
});
|
||||
this.input.on("pointerdown", (pointer, gameObjects = []) => {
|
||||
if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject))) {
|
||||
this.clearSelectedFighter();
|
||||
}
|
||||
});
|
||||
this.input.keyboard?.on("keydown-ESC", () => {
|
||||
this.clearSelectedFighter();
|
||||
});
|
||||
|
||||
this.ready = true;
|
||||
this.startMatch(this.getInitialMatchConfig());
|
||||
@@ -104,6 +118,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.matchId += 1;
|
||||
this.matchOver = false;
|
||||
this.observedCombat = [];
|
||||
this.clearSelectedFighter();
|
||||
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
|
||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
clearCombatObjects(this);
|
||||
@@ -122,6 +137,20 @@ export class ArenaScene extends Phaser.Scene {
|
||||
update(time) {
|
||||
this.fighters.forEach(syncFighterHud);
|
||||
|
||||
if (!this.matchOver) {
|
||||
this.fighters.forEach((fighter) => {
|
||||
updateFighter(this, fighter, time, () => {
|
||||
this.updateScoreboard();
|
||||
this.finishMatch();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.focusSelectedFighter()) {
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.matchOver) {
|
||||
this.updateMinimapViewportFrame();
|
||||
return;
|
||||
@@ -150,16 +179,56 @@ update(time) {
|
||||
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
|
||||
}
|
||||
|
||||
this.fighters.forEach((fighter) => {
|
||||
updateFighter(this, fighter, time, () => {
|
||||
this.updateScoreboard();
|
||||
this.finishMatch();
|
||||
});
|
||||
});
|
||||
|
||||
this.updateMinimapViewportFrame();
|
||||
}
|
||||
|
||||
selectFighter(fighter) {
|
||||
if (!isLivingFighter(fighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedFighter === fighter) {
|
||||
this.clearSelectedFighter();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearSelectedFighter();
|
||||
this.selectedFighter = fighter;
|
||||
fighter.isSelected = true;
|
||||
this.observedCombat = [];
|
||||
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, SELECTED_FIGHTER_CAMERA_ZOOM));
|
||||
this.centerCameraOnFighter(fighter);
|
||||
syncFighterHud(fighter);
|
||||
}
|
||||
|
||||
clearSelectedFighter() {
|
||||
if (this.selectedFighter) {
|
||||
this.selectedFighter.isSelected = false;
|
||||
syncFighterHud(this.selectedFighter);
|
||||
}
|
||||
|
||||
this.selectedFighter = null;
|
||||
}
|
||||
|
||||
focusSelectedFighter() {
|
||||
if (!this.selectedFighter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isLivingFighter(this.selectedFighter)) {
|
||||
this.clearSelectedFighter();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.centerCameraOnFighter(this.selectedFighter);
|
||||
return true;
|
||||
}
|
||||
|
||||
centerCameraOnFighter(fighter) {
|
||||
const target = fighter.body?.center ?? fighter;
|
||||
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
|
||||
}
|
||||
|
||||
setMainCameraZoom(zoom) {
|
||||
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
|
||||
|
||||
|
||||
+60
-12
@@ -1,8 +1,14 @@
|
||||
import Phaser from "phaser";
|
||||
import {
|
||||
ATTACK_COOLDOWN,
|
||||
ATTACK_DAMAGE_MAX,
|
||||
ATTACK_DAMAGE_MIN,
|
||||
ATTACK_RANGE,
|
||||
FIGHTER_MAX_HP,
|
||||
FIGHTER_SCALE,
|
||||
KILL_HEALTH_RECOVERY_RATIO,
|
||||
KILL_GROWTH_MULTIPLIER,
|
||||
KILL_GROWTH_TWEEN_DURATION,
|
||||
MELEE_HIT_DELAY,
|
||||
MELEE_CRITICAL_CHANCE,
|
||||
MOVE_SPEED,
|
||||
@@ -41,7 +47,7 @@ export function updateFighter(scene, fighter, time, onWinner) {
|
||||
fighter.setFlipX(enemy.x < fighter.x);
|
||||
|
||||
if (distance > getAttackRange(fighter)) {
|
||||
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier());
|
||||
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * fighterMovementSpeedMultiplier(fighter));
|
||||
playIfNeeded(fighter, "walk");
|
||||
return;
|
||||
}
|
||||
@@ -66,10 +72,11 @@ export function clearCombatObjects(scene) {
|
||||
|
||||
function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
const attack = createAttackProfile(attacker);
|
||||
attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN);
|
||||
attacker.nextAttackAt =
|
||||
time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
|
||||
attacker.isLocked = true;
|
||||
scene.observeCombat?.(attacker, defender);
|
||||
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
|
||||
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
|
||||
|
||||
switch (getCombatType(attacker)) {
|
||||
case "projectile":
|
||||
@@ -86,7 +93,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY), () => {
|
||||
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
instantKill: attack.isCritical,
|
||||
});
|
||||
@@ -96,7 +103,7 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||
function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY), () => {
|
||||
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +115,7 @@ function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY), () => {
|
||||
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +151,8 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
projectile,
|
||||
defenderHitPoint.x,
|
||||
defenderHitPoint.y,
|
||||
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
|
||||
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
|
||||
fighterAttackSpeedMultiplier(attacker),
|
||||
);
|
||||
trackCombatObject(scene, projectile);
|
||||
|
||||
@@ -209,9 +217,12 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||
disposeCombatObject(scene, effect);
|
||||
});
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY), () => {
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
|
||||
() => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
|
||||
@@ -219,7 +230,9 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
|
||||
return;
|
||||
}
|
||||
|
||||
defender.hp = instantKill ? 0 : Math.max(0, defender.hp - Phaser.Math.Between(14, 24));
|
||||
defender.hp = instantKill
|
||||
? 0
|
||||
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
|
||||
defender.body.setVelocity(0, 0);
|
||||
|
||||
if (defender.hp === 0) {
|
||||
@@ -336,9 +349,36 @@ function killFighter(defender, winner, onWinner) {
|
||||
winner.isLocked = false;
|
||||
winner.body.setVelocity(0, 0);
|
||||
playAnimation(winner, "idle");
|
||||
applyKillReward(winner);
|
||||
onWinner(winner);
|
||||
}
|
||||
|
||||
function applyKillReward(winner) {
|
||||
winner.killCount = (winner.killCount ?? 0) + 1;
|
||||
|
||||
const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount;
|
||||
winner.killRewardMultiplier = rewardMultiplier;
|
||||
winner.hp = recoveredHealth(winner);
|
||||
|
||||
const nextScaleX = (winner.baseScaleX ?? FIGHTER_SCALE) * rewardMultiplier;
|
||||
const nextScaleY = (winner.baseScaleY ?? FIGHTER_SCALE) * rewardMultiplier;
|
||||
|
||||
winner.scene.tweens.add({
|
||||
targets: winner,
|
||||
scaleX: nextScaleX,
|
||||
scaleY: nextScaleY,
|
||||
duration: KILL_GROWTH_TWEEN_DURATION,
|
||||
ease: "Back.Out",
|
||||
});
|
||||
}
|
||||
|
||||
function recoveredHealth(fighter) {
|
||||
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
|
||||
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
|
||||
|
||||
return Math.min(maxHp, fighter.hp + recovery);
|
||||
}
|
||||
|
||||
function findNearestEnemy(fighters, fighter) {
|
||||
let nearestEnemy;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
@@ -381,8 +421,16 @@ function playAnimation(fighter, action, timeScale = 1) {
|
||||
fighter.play(fighterAnimationKey(fighter.skin, action), true);
|
||||
}
|
||||
|
||||
function scaledAttackDelay(duration) {
|
||||
return duration / getAttackSpeedMultiplier();
|
||||
function scaledAttackDelay(duration, fighter) {
|
||||
return duration / fighterAttackSpeedMultiplier(fighter);
|
||||
}
|
||||
|
||||
function fighterAttackSpeedMultiplier(fighter) {
|
||||
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||
}
|
||||
|
||||
function fighterMovementSpeedMultiplier(fighter) {
|
||||
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||
}
|
||||
|
||||
function trackCombatObject(scene, object) {
|
||||
|
||||
+147
-16
@@ -1,4 +1,16 @@
|
||||
import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js";
|
||||
import {
|
||||
FIGHTER_ANIMATION_OPTIONS,
|
||||
FIGHTER_FRAME_HEIGHT,
|
||||
FIGHTER_FRAME_WIDTH,
|
||||
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";
|
||||
|
||||
const SOURCE_ALPHA_THRESHOLD = 8;
|
||||
|
||||
export function fighterSheetKey(skin, action) {
|
||||
return `${skin.key}-${action}`;
|
||||
@@ -8,6 +20,14 @@ export function fighterAnimationKey(skin, action) {
|
||||
return `${fighterSheetKey(skin, action)}-anim`;
|
||||
}
|
||||
|
||||
export function fighterOutlineSheetKey(skin, action) {
|
||||
return `${fighterSheetKey(skin, action)}-outline`;
|
||||
}
|
||||
|
||||
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
|
||||
return `${sheetKey}-outline`;
|
||||
}
|
||||
|
||||
export function fighterAttackEffectKey(skin) {
|
||||
return `${skin.key}-attack-effect`;
|
||||
}
|
||||
@@ -26,7 +46,7 @@ export function preloadFighterSheets(scene, skins) {
|
||||
scene.load.spritesheet(
|
||||
fighterSheetKey(skin, action),
|
||||
`${skin.assetRoot}/${animation.file}`,
|
||||
{ frameWidth: 100, frameHeight: 100 },
|
||||
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -39,21 +59,21 @@ export function createFighterAnimations(scene, skins) {
|
||||
Object.entries(skin.animations).forEach(([action, animation]) => {
|
||||
const key = fighterAnimationKey(skin, action);
|
||||
|
||||
if (scene.anims.exists(key)) {
|
||||
return;
|
||||
if (!scene.anims.exists(key)) {
|
||||
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
|
||||
|
||||
scene.anims.create({
|
||||
key,
|
||||
frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), {
|
||||
start: 0,
|
||||
end: animation.frames - 1,
|
||||
}),
|
||||
frameRate,
|
||||
repeat,
|
||||
});
|
||||
}
|
||||
|
||||
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
|
||||
|
||||
scene.anims.create({
|
||||
key,
|
||||
frames: scene.anims.generateFrameNumbers(fighterSheetKey(skin, action), {
|
||||
start: 0,
|
||||
end: animation.frames - 1,
|
||||
}),
|
||||
frameRate,
|
||||
repeat,
|
||||
});
|
||||
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
||||
});
|
||||
|
||||
createAttackEffectAnimation(scene, skin);
|
||||
@@ -72,7 +92,7 @@ function preloadCombatAssets(scene, skin) {
|
||||
scene.load.spritesheet(
|
||||
fighterAttackEffectKey(skin),
|
||||
`${skin.assetRoot}/${attackEffect.file}`,
|
||||
{ frameWidth: 100, frameHeight: 100 },
|
||||
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -100,3 +120,114 @@ function createAttackEffectAnimation(scene, skin) {
|
||||
repeat: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
||||
const key = fighterOutlineSheetKey(skin, action);
|
||||
|
||||
if (scene.textures.exists(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
||||
const sourceImage = sourceTexture?.getSourceImage?.();
|
||||
|
||||
if (!sourceImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sheetWidth = FIGHTER_FRAME_WIDTH * frameCount;
|
||||
const sheetHeight = FIGHTER_FRAME_HEIGHT;
|
||||
const sourceCanvas = document.createElement("canvas");
|
||||
sourceCanvas.width = sheetWidth;
|
||||
sourceCanvas.height = sheetHeight;
|
||||
|
||||
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
||||
sourceContext.drawImage(sourceImage, 0, 0);
|
||||
|
||||
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
|
||||
const outlineCanvas = document.createElement("canvas");
|
||||
outlineCanvas.width = sheetWidth;
|
||||
outlineCanvas.height = sheetHeight;
|
||||
|
||||
const outlineContext = outlineCanvas.getContext("2d");
|
||||
const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight);
|
||||
const outlineData = outlineImage.data;
|
||||
const gapMask = new Uint8Array(sheetWidth * sheetHeight);
|
||||
const outerMask = new Uint8Array(sheetWidth * sheetHeight);
|
||||
const outlineAlpha = Math.round(SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
|
||||
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||
const frameLeft = frameIndex * FIGHTER_FRAME_WIDTH;
|
||||
|
||||
for (let y = 0; y < FIGHTER_FRAME_HEIGHT; y += 1) {
|
||||
for (let x = 0; x < FIGHTER_FRAME_WIDTH; x += 1) {
|
||||
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
||||
|
||||
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
|
||||
outlineContext.putImageData(outlineImage, 0, 0);
|
||||
scene.textures.addSpriteSheet(key, outlineCanvas, {
|
||||
frameWidth: FIGHTER_FRAME_WIDTH,
|
||||
frameHeight: FIGHTER_FRAME_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
|
||||
const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH;
|
||||
|
||||
for (
|
||||
let offsetY = -outerRadius;
|
||||
offsetY <= outerRadius;
|
||||
offsetY += 1
|
||||
) {
|
||||
const targetY = sourceY + offsetY;
|
||||
|
||||
if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (
|
||||
let offsetX = -outerRadius;
|
||||
offsetX <= outerRadius;
|
||||
offsetX += 1
|
||||
) {
|
||||
const targetX = sourceX + offsetX;
|
||||
|
||||
if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
||||
|
||||
outerMask[maskIndex] = 1;
|
||||
|
||||
if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) {
|
||||
gapMask[maskIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
||||
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
|
||||
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 + 3] = outlineAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
+82
-11
@@ -1,6 +1,21 @@
|
||||
import Phaser from "phaser";
|
||||
import { FIGHTER_SCALE } from "../constants.js";
|
||||
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
|
||||
import {
|
||||
FIGHTER_FRAME_HEIGHT,
|
||||
FIGHTER_FRAME_WIDTH,
|
||||
FIGHTER_HITBOX_HEIGHT,
|
||||
FIGHTER_HITBOX_OFFSET_X,
|
||||
FIGHTER_HITBOX_OFFSET_Y,
|
||||
FIGHTER_HITBOX_WIDTH,
|
||||
FIGHTER_MAX_HP,
|
||||
FIGHTER_SCALE,
|
||||
} from "../constants.js";
|
||||
import {
|
||||
fighterAnimationKey,
|
||||
fighterOutlineSheetKeyFromSheetKey,
|
||||
fighterSheetKey,
|
||||
} from "./fighterAssets.js";
|
||||
|
||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
||||
|
||||
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
|
||||
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
|
||||
@@ -8,11 +23,26 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
fighter.setDepth(2);
|
||||
fighter.setCollideWorldBounds(true);
|
||||
fighter.setFlipX(faceLeft);
|
||||
fighter.body.setSize(22, 20);
|
||||
fighter.body.setOffset(39, 60);
|
||||
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
|
||||
fighter.body.setOffset(FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_Y);
|
||||
fighter.setInteractive(
|
||||
new Phaser.Geom.Rectangle(
|
||||
FIGHTER_HITBOX_OFFSET_X,
|
||||
FIGHTER_HITBOX_OFFSET_Y,
|
||||
FIGHTER_HITBOX_WIDTH,
|
||||
FIGHTER_HITBOX_HEIGHT,
|
||||
),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
fighter.input.cursor = "pointer";
|
||||
fighter.selectionOutline = scene.add
|
||||
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
|
||||
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
|
||||
.setDepth(1.9)
|
||||
.setVisible(false);
|
||||
|
||||
fighter.nameLabel = scene.add
|
||||
.text(x, y - 68, name, {
|
||||
.text(x, y, name, {
|
||||
color: "#fff2c2",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "18px",
|
||||
@@ -20,7 +50,7 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
stroke: team.color,
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setOrigin(0.5, 0)
|
||||
.setDepth(4);
|
||||
fighter.healthBack = scene.add
|
||||
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
|
||||
@@ -33,7 +63,13 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
fighter.skin = skin;
|
||||
fighter.team = team;
|
||||
fighter.teamIndex = teamIndex;
|
||||
fighter.hp = 100;
|
||||
fighter.baseScaleX = FIGHTER_SCALE;
|
||||
fighter.baseScaleY = FIGHTER_SCALE;
|
||||
fighter.isSelected = false;
|
||||
fighter.killCount = 0;
|
||||
fighter.killRewardMultiplier = 1;
|
||||
fighter.maxHp = FIGHTER_MAX_HP;
|
||||
fighter.hp = fighter.maxHp;
|
||||
fighter.nextAttackAt = 0;
|
||||
fighter.isLocked = false;
|
||||
fighter.isDead = false;
|
||||
@@ -50,21 +86,56 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
|
||||
});
|
||||
|
||||
attachHudCleanup(fighter);
|
||||
syncFighterHud(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));
|
||||
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
|
||||
const healthOffset = 44 * scaleRatio;
|
||||
const hitbox = fighter.body;
|
||||
const nameX = hitbox.x + hitbox.width / 2;
|
||||
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
|
||||
|
||||
fighter.nameLabel.setPosition(nameX, nameY);
|
||||
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
|
||||
syncSelectionOutline(fighter);
|
||||
}
|
||||
|
||||
function syncSelectionOutline(fighter) {
|
||||
const outline = fighter.selectionOutline;
|
||||
|
||||
if (!outline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = Boolean(fighter.isSelected && !fighter.isDead);
|
||||
outline.setVisible(isVisible);
|
||||
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
||||
|
||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
||||
outline.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);
|
||||
}
|
||||
|
||||
function attachHudCleanup(fighter) {
|
||||
const originalDestroy = fighter.destroy.bind(fighter);
|
||||
|
||||
fighter.destroy = (...args) => {
|
||||
fighter.selectionOutline.destroy();
|
||||
fighter.nameLabel.destroy();
|
||||
fighter.healthBack.destroy();
|
||||
fighter.healthBar.destroy();
|
||||
|
||||
+19
@@ -2,6 +2,7 @@ import Phaser from "phaser";
|
||||
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();
|
||||
@@ -32,4 +33,22 @@ const game = new Phaser.Game({
|
||||
|
||||
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
|
||||
|
||||
const visitorCountNode = document.querySelector("#visitor-count");
|
||||
|
||||
trackVisitor({
|
||||
onUpdate({ uniqueVisitors }) {
|
||||
if (visitorCountNode) {
|
||||
visitorCountNode.textContent = `방문자 ${uniqueVisitors.toLocaleString("ko-KR")}`;
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
console.warn(error);
|
||||
|
||||
if (visitorCountNode) {
|
||||
visitorCountNode.textContent = "";
|
||||
visitorCountNode.hidden = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
window.arenaGame = game;
|
||||
|
||||
@@ -65,6 +65,18 @@ h1 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
width: fit-content;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(230 207 134 / 0.18);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgb(29 32 23 / 0.74);
|
||||
color: #f1d892;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function trackVisitor({ onUpdate, onError } = {}) {
|
||||
try {
|
||||
const response = await fetch("/api/visitors/check", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Visitor check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const visitorStats = await response.json();
|
||||
onUpdate?.(visitorStats);
|
||||
return visitorStats;
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user