Add critical hits, victory celebrations, and enhanced match settings
This commit is contained in:
+314
-52
@@ -4,12 +4,14 @@ import {
|
||||
CAMERA_MAX_ZOOM,
|
||||
CAMERA_MIN_ZOOM,
|
||||
CAMERA_ZOOM_STEP,
|
||||
FINAL_COMBAT_SLOW_MOTION_DURATION,
|
||||
FINAL_COMBAT_SLOW_MOTION_ENABLED,
|
||||
FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION,
|
||||
FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION,
|
||||
FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION,
|
||||
FINAL_COMBAT_SLOW_MOTION_SCALE,
|
||||
MINIMAP_ALPHA,
|
||||
MINIMAP_MARGIN,
|
||||
MINIMAP_VIEWPORT_SIZE,
|
||||
MINIMAP_VIEW_FRAME_OUTLINE,
|
||||
MINIMAP_VIEW_FRAME_STROKE,
|
||||
SELECTED_FIGHTER_CAMERA_ZOOM,
|
||||
SPECTATOR_CAMERA_LERP,
|
||||
@@ -44,14 +46,10 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.setStatus = (message) => {
|
||||
this.updateStatus(message);
|
||||
|
||||
const oldBanner = document.querySelector(".victory-banner");
|
||||
if (oldBanner) oldBanner.remove();
|
||||
removeVictoryCelebration();
|
||||
|
||||
if (message.includes("승리") || message.includes("무승부")) {
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "victory-banner";
|
||||
banner.textContent = message;
|
||||
document.querySelector(".arena-shell")?.appendChild(banner);
|
||||
createVictoryCelebration(message);
|
||||
}
|
||||
};
|
||||
this.observedCombat = [];
|
||||
@@ -71,6 +69,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.spectatorMode = null;
|
||||
this.slowMotionRestoreState = null;
|
||||
this.slowMotionTimer = null;
|
||||
this.slowMotionTransitionFrame = null;
|
||||
}
|
||||
|
||||
preload() {
|
||||
@@ -125,7 +124,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
this.startMatch(this.getInitialMatchConfig(), { silent: true });
|
||||
}
|
||||
|
||||
startMatch({ names = [], teamSize } = {}, { silent = false } = {}) {
|
||||
startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) {
|
||||
if (!this.ready) {
|
||||
return;
|
||||
}
|
||||
@@ -135,9 +134,15 @@ export class ArenaScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize);
|
||||
if (!silent) {
|
||||
primeVictoryFanfareAudio();
|
||||
}
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize, spawnPlacement);
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
|
||||
expandSpawnMultipliers: !silent,
|
||||
});
|
||||
syncTeamSizes(matchSetup.teams, fighterPlans);
|
||||
|
||||
this.matchId += 1;
|
||||
@@ -354,7 +359,7 @@ export class ArenaScene extends Phaser.Scene {
|
||||
weapon.className = "kill-log-weapon";
|
||||
weapon.setAttribute("aria-hidden", "true");
|
||||
actionText.className = "kill-log-action-text";
|
||||
actionText.textContent = "처치 >";
|
||||
actionText.textContent = "처치";
|
||||
action.append(weapon, actionText);
|
||||
|
||||
item.append(
|
||||
@@ -499,27 +504,95 @@ update(time) {
|
||||
}
|
||||
|
||||
triggerFinalCombatSlowMotion() {
|
||||
if (this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive()) {
|
||||
if (
|
||||
!FINAL_COMBAT_SLOW_MOTION_ENABLED
|
||||
|| this.presentationMode
|
||||
|| this.matchOver
|
||||
|| this.matchPaused
|
||||
|| !this.isFinalCombatActive()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.slowMotionRestoreState) {
|
||||
this.slowMotionRestoreState = {
|
||||
animations: this.anims?.globalTimeScale ?? 1,
|
||||
clock: this.time?.timeScale ?? 1,
|
||||
physics: this.physics?.world?.timeScale ?? 1,
|
||||
tweens: this.tweens?.timeScale ?? 1,
|
||||
};
|
||||
this.applySceneTimeScale(FINAL_COMBAT_SLOW_MOTION_SCALE);
|
||||
if (this.slowMotionRestoreState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionRestoreState = {
|
||||
animations: this.anims?.globalTimeScale ?? 1,
|
||||
clock: this.time?.timeScale ?? 1,
|
||||
physics: this.physics?.world?.timeScale ?? 1,
|
||||
tweens: this.tweens?.timeScale ?? 1,
|
||||
};
|
||||
|
||||
this.transitionSceneTimeScale(
|
||||
FINAL_COMBAT_SLOW_MOTION_SCALE,
|
||||
FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION,
|
||||
easeOutCubic,
|
||||
() => this.holdFinalCombatSlowMotion(),
|
||||
);
|
||||
}
|
||||
|
||||
holdFinalCombatSlowMotion() {
|
||||
if (!this.slowMotionRestoreState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.slowMotionTimer = globalThis.setTimeout(() => {
|
||||
this.clearFinalCombatEffects();
|
||||
}, FINAL_COMBAT_SLOW_MOTION_DURATION);
|
||||
this.slowMotionTimer = null;
|
||||
this.releaseFinalCombatSlowMotion();
|
||||
}, FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION);
|
||||
}
|
||||
|
||||
releaseFinalCombatSlowMotion() {
|
||||
const restore = this.slowMotionRestoreState;
|
||||
|
||||
if (!restore) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transitionSceneTimeScale(
|
||||
restore.clock,
|
||||
FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION,
|
||||
easeInOutCubic,
|
||||
() => {
|
||||
if (this.slowMotionRestoreState !== restore) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.slowMotionRestoreState = null;
|
||||
this.restoreSceneTimeScale(restore);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
transitionSceneTimeScale(targetScale, duration, ease, onComplete) {
|
||||
this.cancelSlowMotionTransition();
|
||||
|
||||
const startScale = this.time?.timeScale ?? 1;
|
||||
|
||||
if (duration <= 0 || Math.abs(startScale - targetScale) < 0.001) {
|
||||
this.applySceneTimeScale(targetScale);
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = globalThis.performance.now();
|
||||
|
||||
const updateScale = (now) => {
|
||||
const progress = Math.min(1, Math.max(0, (now - startedAt) / duration));
|
||||
this.applySceneTimeScale(startScale + (targetScale - startScale) * ease(progress));
|
||||
|
||||
if (progress >= 1) {
|
||||
this.slowMotionTransitionFrame = null;
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale);
|
||||
};
|
||||
|
||||
this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale);
|
||||
}
|
||||
|
||||
applySceneTimeScale(scale) {
|
||||
@@ -528,7 +601,8 @@ update(time) {
|
||||
}
|
||||
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = scale;
|
||||
// Arcade Physics uses larger timeScale values for slower world steps.
|
||||
this.physics.world.timeScale = arcadePhysicsTimeScale(scale);
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
@@ -541,30 +615,45 @@ update(time) {
|
||||
}
|
||||
|
||||
clearFinalCombatEffects() {
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
this.clearSlowMotionTimer();
|
||||
this.cancelSlowMotionTransition();
|
||||
|
||||
if (this.slowMotionRestoreState) {
|
||||
const restore = this.slowMotionRestoreState;
|
||||
this.slowMotionRestoreState = null;
|
||||
this.restoreSceneTimeScale(restore);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.time) {
|
||||
this.time.timeScale = restore.clock;
|
||||
}
|
||||
clearSlowMotionTimer() {
|
||||
if (this.slowMotionTimer) {
|
||||
globalThis.clearTimeout(this.slowMotionTimer);
|
||||
this.slowMotionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = restore.physics;
|
||||
}
|
||||
cancelSlowMotionTransition() {
|
||||
if (this.slowMotionTransitionFrame !== null) {
|
||||
globalThis.cancelAnimationFrame(this.slowMotionTransitionFrame);
|
||||
this.slowMotionTransitionFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
this.tweens.timeScale = restore.tweens;
|
||||
}
|
||||
restoreSceneTimeScale(restore) {
|
||||
if (this.time) {
|
||||
this.time.timeScale = restore.clock;
|
||||
}
|
||||
|
||||
if (this.anims) {
|
||||
this.anims.globalTimeScale = restore.animations;
|
||||
}
|
||||
if (this.physics?.world) {
|
||||
this.physics.world.timeScale = restore.physics;
|
||||
}
|
||||
|
||||
if (this.tweens) {
|
||||
this.tweens.timeScale = restore.tweens;
|
||||
}
|
||||
|
||||
if (this.anims) {
|
||||
this.anims.globalTimeScale = restore.animations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,19 +834,45 @@ update(time) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE);
|
||||
const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE);
|
||||
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA_SIZE));
|
||||
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA_SIZE));
|
||||
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
|
||||
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
|
||||
const cameraMidX = scrollX + camera.width / 2;
|
||||
const cameraMidY = scrollY + camera.height / 2;
|
||||
const frameX = Phaser.Math.Clamp(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth);
|
||||
const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight);
|
||||
const frameX = Phaser.Math.Clamp(
|
||||
this.snapMinimapFrameValue(cameraMidX - frameWidth / 2),
|
||||
0,
|
||||
ARENA_SIZE - frameWidth,
|
||||
);
|
||||
const frameY = Phaser.Math.Clamp(
|
||||
this.snapMinimapFrameValue(cameraMidY - frameHeight / 2),
|
||||
0,
|
||||
ARENA_SIZE - frameHeight,
|
||||
);
|
||||
|
||||
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1);
|
||||
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
|
||||
this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight);
|
||||
}
|
||||
|
||||
drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) {
|
||||
const stroke = Math.min(MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
|
||||
const sideHeight = Math.max(0, frameHeight - stroke * 2);
|
||||
|
||||
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke);
|
||||
this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight);
|
||||
this.minimapViewportFrame.fillRect(
|
||||
frameX + frameWidth - stroke,
|
||||
frameY + stroke,
|
||||
stroke,
|
||||
sideHeight,
|
||||
);
|
||||
}
|
||||
|
||||
snapMinimapFrameValue(value) {
|
||||
const minimapZoom = this.minimapCamera?.zoom ?? 1;
|
||||
return Math.round(value * minimapZoom) / minimapZoom;
|
||||
}
|
||||
|
||||
observeCombat(attacker, defender) {
|
||||
@@ -912,6 +1027,151 @@ const DEATH_NOTICE_TEMPLATES = [
|
||||
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
|
||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||
];
|
||||
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||
const VICTORY_CONFETTI_COUNT = 40;
|
||||
const VICTORY_FANFARE_NOTES = [
|
||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
||||
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
|
||||
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
|
||||
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
|
||||
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
|
||||
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
|
||||
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
|
||||
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
|
||||
];
|
||||
|
||||
let victoryAudioContext = null;
|
||||
|
||||
function removeVictoryCelebration() {
|
||||
document.querySelector(".victory-celebration")?.remove();
|
||||
}
|
||||
|
||||
function createVictoryCelebration(message) {
|
||||
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
|
||||
|
||||
if (!celebrationHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVictory = message.includes("승리");
|
||||
const celebration = document.createElement("div");
|
||||
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
|
||||
celebration.setAttribute("aria-hidden", "true");
|
||||
|
||||
const rays = document.createElement("span");
|
||||
rays.className = "victory-rays";
|
||||
|
||||
const confetti = document.createElement("span");
|
||||
confetti.className = "victory-confetti";
|
||||
|
||||
if (isVictory) {
|
||||
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
|
||||
confetti.appendChild(createVictoryConfettiPiece(index));
|
||||
});
|
||||
}
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "victory-banner";
|
||||
|
||||
const messageNode = document.createElement("span");
|
||||
messageNode.className = "victory-banner-message";
|
||||
messageNode.textContent = message;
|
||||
|
||||
banner.appendChild(messageNode);
|
||||
celebration.append(rays, confetti, banner);
|
||||
celebrationHost.appendChild(celebration);
|
||||
|
||||
if (isVictory) {
|
||||
playVictoryFanfare();
|
||||
}
|
||||
}
|
||||
|
||||
function createVictoryConfettiPiece(index) {
|
||||
const piece = document.createElement("i");
|
||||
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
|
||||
const distance = 170 + (index % 8) * 26;
|
||||
const x = Math.round(Math.cos(angle) * distance);
|
||||
const y = Math.round(Math.sin(angle) * distance * 0.78);
|
||||
|
||||
piece.className = "victory-confetti-piece";
|
||||
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
|
||||
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
|
||||
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
|
||||
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
|
||||
piece.style.setProperty("--confetti-x", `${x}px`);
|
||||
piece.style.setProperty("--confetti-y", `${y}px`);
|
||||
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
function primeVictoryFanfareAudio() {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||
|
||||
if (!AudioContextClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!victoryAudioContext) {
|
||||
victoryAudioContext = new AudioContextClass();
|
||||
}
|
||||
|
||||
if (victoryAudioContext.state === "suspended") {
|
||||
victoryAudioContext.resume().catch(() => {});
|
||||
}
|
||||
|
||||
return victoryAudioContext;
|
||||
}
|
||||
|
||||
function playVictoryFanfare() {
|
||||
const audioContext = primeVictoryFanfareAudio();
|
||||
|
||||
if (!audioContext || audioContext.state !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const startAt = audioContext.currentTime + 0.03;
|
||||
|
||||
VICTORY_FANFARE_NOTES.forEach((note) => {
|
||||
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
|
||||
});
|
||||
}
|
||||
|
||||
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
const releaseAt = startAt + duration;
|
||||
|
||||
oscillator.type = "triangle";
|
||||
oscillator.frequency.setValueAtTime(frequency, startAt);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
oscillator.start(startAt);
|
||||
oscillator.stop(releaseAt + 0.02);
|
||||
}
|
||||
|
||||
function easeOutCubic(progress) {
|
||||
return 1 - (1 - progress) ** 3;
|
||||
}
|
||||
|
||||
function easeInOutCubic(progress) {
|
||||
if (progress < 0.5) {
|
||||
return 4 * progress ** 3;
|
||||
}
|
||||
|
||||
return 1 - ((-2 * progress + 2) ** 3) / 2;
|
||||
}
|
||||
|
||||
function arcadePhysicsTimeScale(sceneTimeScale) {
|
||||
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
|
||||
}
|
||||
|
||||
function createDeathCounts() {
|
||||
return SPECIES_KEYS.reduce((counts, species) => {
|
||||
@@ -1000,10 +1260,12 @@ function fighterSkinIdleUrl(skin) {
|
||||
return `${skin.assetRoot}/${idleFile}`;
|
||||
}
|
||||
|
||||
function createFighterPlans(fighterSetups, skins) {
|
||||
function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||
const skin = skins[index];
|
||||
const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1));
|
||||
const spawnMultiplier = expandSpawnMultipliers
|
||||
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
||||
: 1;
|
||||
|
||||
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
|
||||
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
|
||||
|
||||
+53
-17
@@ -87,10 +87,10 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
|
||||
|
||||
switch (getCombatType(attacker)) {
|
||||
case "projectile":
|
||||
queueProjectile(scene, attacker, defender, onWinner);
|
||||
queueProjectile(scene, attacker, defender, onWinner, attack);
|
||||
return;
|
||||
case "instant-spell":
|
||||
queueInstantSpell(scene, attacker, defender, onWinner);
|
||||
queueInstantSpell(scene, attacker, defender, onWinner, attack);
|
||||
return;
|
||||
default:
|
||||
queueMeleeHit(scene, attacker, defender, onWinner, attack);
|
||||
@@ -102,12 +102,12 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
instantKill: attack.isCritical,
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
function queueProjectile(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
|
||||
@@ -115,11 +115,11 @@ function queueProjectile(scene, attacker, defender, onWinner) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId);
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
}
|
||||
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
|
||||
@@ -127,11 +127,11 @@ function queueInstantSpell(scene, attacker, defender, onWinner) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId);
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
const defenderHitPoint = fighterHitPoint(defender);
|
||||
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
|
||||
const projectile = scene.physics.add.image(
|
||||
@@ -178,7 +178,9 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
|
||||
projectile.hasHit = true;
|
||||
disposeCombatObject(scene, projectile);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
};
|
||||
|
||||
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
|
||||
@@ -213,7 +215,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
|
||||
});
|
||||
}
|
||||
|
||||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
|
||||
effect.setDepth(3);
|
||||
effect.setScale(FIGHTER_SCALE);
|
||||
@@ -227,17 +229,24 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
|
||||
() => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId);
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
|
||||
function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = false } = {}) {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defender.hp = instantKill
|
||||
if (isCritical) {
|
||||
spawnCriticalHitLabel(scene, defender);
|
||||
scene.cameras.main.shake(90, 0.002);
|
||||
}
|
||||
|
||||
defender.hp = isCritical
|
||||
? 0
|
||||
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
|
||||
defender.body.setVelocity(0, 0);
|
||||
@@ -249,10 +258,37 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
|
||||
|
||||
defender.isLocked = true;
|
||||
playAnimation(defender, "hurt");
|
||||
|
||||
if (instantKill) {
|
||||
scene.cameras.main.shake(90, 0.002);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnCriticalHitLabel(scene, defender) {
|
||||
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER_SCALE);
|
||||
const label = scene.add
|
||||
.text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", {
|
||||
color: "#ffe45c",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "24px",
|
||||
fontStyle: "900",
|
||||
stroke: "#7b1b11",
|
||||
strokeThickness: 5,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(6);
|
||||
|
||||
label.cleanup = () => {
|
||||
scene.tweens.killTweensOf(label);
|
||||
};
|
||||
|
||||
trackCombatObject(scene, label);
|
||||
scene.tweens.add({
|
||||
targets: label,
|
||||
y: label.y - 32,
|
||||
alpha: 0,
|
||||
scaleX: 1.12,
|
||||
scaleY: 1.12,
|
||||
duration: 520,
|
||||
ease: "Cubic.Out",
|
||||
onComplete: () => disposeCombatObject(scene, label),
|
||||
});
|
||||
}
|
||||
|
||||
function getAttackRange(fighter) {
|
||||
|
||||
+67
-5
@@ -1,13 +1,19 @@
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
DEFAULT_SPAWN_PLACEMENT,
|
||||
DEFAULT_TEAM_SIZE,
|
||||
GRID_SIZE,
|
||||
getTeamColor,
|
||||
MAX_TEAM_SIZE,
|
||||
SPAWN_PLACEMENTS,
|
||||
TILE_SIZE,
|
||||
} from "../constants.js";
|
||||
|
||||
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
export function createMatchSetup(
|
||||
names,
|
||||
requestedTeamSize = DEFAULT_TEAM_SIZE,
|
||||
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
|
||||
) {
|
||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||
const teams = names.map((name, index) => ({
|
||||
color: getTeamColor(index, names.length),
|
||||
@@ -16,8 +22,7 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||
size: teamSize,
|
||||
}));
|
||||
|
||||
const totalFighters = names.length * teamSize;
|
||||
const spawns = createRandomSpawnPoints(totalFighters);
|
||||
const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
|
||||
|
||||
const fighters = [];
|
||||
names.forEach((name, teamIndex) => {
|
||||
@@ -58,11 +63,56 @@ function createTeams(playerCount, teamSize) {
|
||||
}));
|
||||
}
|
||||
|
||||
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
|
||||
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
|
||||
return createStartingZoneSpawnPoints(teamCount, teamSize);
|
||||
}
|
||||
|
||||
return createRandomSpawnPoints(teamCount * teamSize);
|
||||
}
|
||||
|
||||
function createRandomSpawnPoints(count) {
|
||||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||
}
|
||||
|
||||
function createStartingZoneSpawnPoints(teamCount, teamSize) {
|
||||
const fallbackSlots = createSpawnSlots();
|
||||
const layout = shuffle(createStartingZoneLayout(teamCount));
|
||||
|
||||
return layout.flatMap((zone) => {
|
||||
const zoneSlots = createSpawnSlots(zone);
|
||||
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
|
||||
});
|
||||
}
|
||||
|
||||
function createStartingZoneLayout(teamCount) {
|
||||
const columnCount = Math.max(1, Math.ceil(Math.sqrt(teamCount)));
|
||||
const rowCount = Math.max(1, Math.ceil(teamCount / columnCount));
|
||||
const availableRows = GRID_SIZE - 2;
|
||||
|
||||
return Array.from({ length: teamCount }, (_, index) => {
|
||||
const column = index % columnCount;
|
||||
const row = Math.floor(index / columnCount);
|
||||
|
||||
return {
|
||||
columnEnd: partitionEnd(GRID_SIZE, columnCount, column),
|
||||
columnStart: partitionStart(GRID_SIZE, columnCount, column),
|
||||
rowEnd: 1 + partitionEnd(availableRows, rowCount, row),
|
||||
rowStart: 1 + partitionStart(availableRows, rowCount, row),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createSpawnSlots({
|
||||
columnEnd = GRID_SIZE,
|
||||
columnStart = 0,
|
||||
rowEnd = GRID_SIZE - 1,
|
||||
rowStart = 1,
|
||||
} = {}) {
|
||||
const spawnSlots = [];
|
||||
|
||||
for (let row = 1; row < GRID_SIZE - 1; row += 1) {
|
||||
for (let column = 0; column < GRID_SIZE; column += 1) {
|
||||
for (let row = rowStart; row < rowEnd; row += 1) {
|
||||
for (let column = columnStart; column < columnEnd; column += 1) {
|
||||
spawnSlots.push({
|
||||
x: column * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: row * TILE_SIZE + TILE_SIZE / 2,
|
||||
@@ -70,6 +120,10 @@ function createRandomSpawnPoints(count) {
|
||||
}
|
||||
}
|
||||
|
||||
return spawnSlots;
|
||||
}
|
||||
|
||||
function createSpawnPointsFromSlots(spawnSlots, count) {
|
||||
const points = [];
|
||||
|
||||
while (points.length < count) {
|
||||
@@ -89,6 +143,14 @@ function createRandomSpawnPoints(count) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function partitionStart(size, partCount, partIndex) {
|
||||
return Math.floor((size * partIndex) / partCount);
|
||||
}
|
||||
|
||||
function partitionEnd(size, partCount, partIndex) {
|
||||
return partitionStart(size, partCount, partIndex + 1);
|
||||
}
|
||||
|
||||
function resolveTeamSize(playerCount, requestedTeamSize) {
|
||||
const teamSize = clamp(
|
||||
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
|
||||
|
||||
Reference in New Issue
Block a user