Add visitor tracking and combat selection features

This commit is contained in:
2026-05-22 13:22:26 +09:00
parent 104bf4fe48
commit 1509b0c5dd
20 changed files with 1049 additions and 1220 deletions
+77 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();