feat: Initial project setup
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user