Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lg_rpg_server/public/game_logic.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Client-side game logic that renders the map, sprites, and UI using Phaser.
import { GAME_VIEW, GAME_PHASES, SOCKET_EVENTS } from './shared_constants.js';
import { drawPvp, drawHearts, showWaiting, hideWaiting } from './scene/overlays.js';
import { drawPvp, drawHearts, drawObjective, showWaiting, hideWaiting } from './scene/overlays.js';
import { createLeaderboard, updateLeaderboard, setLeaderboardAnnouncement } from './scene/leaderboard.js';
import { createDecals, updateDecals } from './scene/decals.js';
import { initGameAudio } from './game_audio.js';
Expand Down Expand Up @@ -79,6 +79,7 @@ async function startGame() {
this.serverHearts = [];
this.serverMatch = null;
this.serverPvp = null;
this.serverObjective = null;
this.playerSprites = new Map();
this.enemySprites = new Map();
this.projectileSprites = new Map();
Expand Down Expand Up @@ -157,6 +158,8 @@ async function startGame() {
createLeaderboard(this, map, configData);
// Above the ground but below sprites (which use depth = world y).
this.pvpGraphics = this.add.graphics().setDepth(2);
// Objective markers and rings, drawn just above the PvP overlays.
this.objectiveGraphics = this.add.graphics().setDepth(3);
if (configData.selectedMode === 'zombie') {
createWeather(this);
}
Expand Down Expand Up @@ -201,6 +204,7 @@ async function startGame() {
this.serverHearts = d.hearts || [];
this.serverMatch = d.match || null;
this.serverPvp = d.pvp || null;
this.serverObjective = d.objective || null;
});

socket.on(SOCKET_EVENTS.GAME_OVER, d => {
Expand Down Expand Up @@ -289,6 +293,7 @@ async function startGame() {
this.syncProjectiles();
drawHearts(this);
drawPvp(this);
drawObjective(this);
updateLeaderboard(this);
updateDecals(this);
if (this.rainDrops) updateWeather(this, delta / 1000);
Expand Down
15 changes: 14 additions & 1 deletion lg_rpg_server/public/scene/leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ function clockInfo(scene) {
};
}

// The live task and its countdown, sitting between announcements and the normal status line so the wall always says what everyone is racing for.
function objectiveLine(scene) {
const objective = scene.serverObjective;
if (!objective || objective.phase === 'idle' || !objective.label) return null;
const seconds = Math.max(0, Math.ceil(Number(objective.remainingMs || 0) / 1000));
if (objective.phase === 'reward') {
const names = (objective.winners || []).map((w) => w.name).join(' & ');
return names ? `${names}: pick a power-up! ${seconds}s` : null;
}
return `TASK ${seconds}s - ${objective.label}`;
}

// By team (leading first) in PvP; tied team scores fall back to player stats.
function ranked(scene, isPvp) {
const list = [...(scene.serverFinal?.results || scene.serverPlayers)];
Expand Down Expand Up @@ -158,7 +170,8 @@ export function updateLeaderboard(scene) {
const { label, sub } = clockInfo(scene);
lb.clock.textContent = label;
if (lb.announcement && scene.time.now >= lb.announcementUntil) lb.announcement = null;
lb.sub.textContent = lb.announcement || sub;
// Announcements come first (that's the "X took Shield" moment), then the task, then the usual line.
lb.sub.textContent = lb.announcement || objectiveLine(scene) || sub;

const players = ranked(scene, isPvp);
// Filled and empty seats build every column the same way, so an unclaimed slot can't shift left by the width of the PvP team tag.
Expand Down
49 changes: 49 additions & 0 deletions lg_rpg_server/public/scene/overlays.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,55 @@ export function drawPvp(scene) {
}
}

// Objective markers: the gold circle or crate players race to, plus rings under the runaway zombie and the bounty leader. Everything pulses, because on a wall this wide a still outline is easy to walk straight past.
const OBJECTIVE_GOLD = 0xffc93c;

export function drawObjective(scene) {
const g = scene.objectiveGraphics?.clear();
const objective = scene.serverObjective;
if (!g || !objective || objective.phase !== 'running') return;

const off = scene.cameraOffset, W = GAME_VIEW.screenWidth;
// One shared beat, so the marker and the rings pulse together instead of shimmering.
const pulse = 0.5 + 0.5 * Math.sin(scene.time.now / 190);
const onScreen = (x) => x > -60 && x < W + 60;

const marker = objective.marker;
if (marker) {
const mx = marker.x - off;
if (onScreen(mx)) {
g.fillStyle(OBJECTIVE_GOLD, 0.12 + 0.10 * pulse);
g.fillCircle(mx, marker.y, marker.radius);
g.lineStyle(3, OBJECTIVE_GOLD, 0.65 + 0.35 * pulse);
g.strokeCircle(mx, marker.y, marker.radius);
// A second ring pushing outward reads as "come here" from across the rig.
g.lineStyle(2, OBJECTIVE_GOLD, 0.35 * (1 - pulse));
g.strokeCircle(mx, marker.y, marker.radius + 10 * pulse);
}
}

// The runaway uses the rat art, so without this ring it disappears into a crowd.
if (objective.runnerId) {
const runner = scene.serverEnemies.find((e) => e.id === objective.runnerId && !e.dying);
if (runner) ringTarget(g, runner.x - off, runner.y, pulse, onScreen);
}

if (objective.bountyId) {
const marked = scene.serverPlayers.find((p) => p.playerId === objective.bountyId && !p.dead);
if (marked) ringTarget(g, marked.x - off, marked.y, pulse, onScreen);
}
}

// A flat ring at the feet. An ellipse, so it looks like it is lying on the ground.
function ringTarget(g, lx, y, pulse, onScreen) {
if (!onScreen(lx)) return;
const rx = 20 + 4 * pulse;
g.lineStyle(3, OBJECTIVE_GOLD, 0.6 + 0.4 * pulse);
g.strokeEllipse(lx, y - 2, rx * 2, rx);
g.fillStyle(OBJECTIVE_GOLD, 0.10 + 0.08 * pulse);
g.fillEllipse(lx, y - 2, rx * 2, rx);
}

// Syncs the animated heart pickup sprites with server state.
export function drawHearts(scene) {
const activeIds = new Set();
Expand Down
11 changes: 11 additions & 0 deletions lg_rpg_server/src/game-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function startGameLoop() {
if (amount <= 0) continue;
}
if ((player.shieldUntil || 0) > now) continue;
// Only damage that gets through breaks a clean run. A fully blocked hit does not.
state.objectives?.onDamage(player.playerId);
player.health = Math.max(0, player.health - amount);
if (player.health === 0) {
player.dead = true;
Expand All @@ -91,13 +93,16 @@ export function startGameLoop() {
player.velocityX = 0;
player.velocityY = 0;
emitGameEvent('player_died', { playerId: player.playerId, name: player.name });
state.objectives?.onPlayerDied(player.playerId);

// Credit at the moment health hits 0, so blocked hits can't inflate the score.
const killer = hit.attackerId ? state.players.get(hit.attackerId) : null;
if (killer && killer !== player) {
killer.kills = (killer.kills || 0) + 1;
emitGameEvent('kill', { playerId: killer.playerId, name: killer.name, kills: killer.kills });
}
// Kill counts come from the task's own snapshot; this just says who killed whom, the one thing the bounty task can't work out on its own.
state.objectives?.onPlayerKilled(killer?.playerId ?? null, player.playerId);

if (player.socketId) {
io.to(player.socketId).emit(SOCKET_EVENTS.YOU_DIED, { playerId: player.playerId });
Expand Down Expand Up @@ -131,10 +136,14 @@ export function startGameLoop() {
if (heal > 0) {
player.health = Math.min(player.maxHealth, player.health + heal);
if (player.health > player.maxHealth * 0.3) player.lowHealthSignaled = false;
state.objectives?.onHeart(player.playerId);
}
}
}

// Inside the mode block, so tasks stop when the game stops.
state.objectives?.tick();

// End the match via mode result (PvP) or death/timer (co-op).
if (state.matchActive && state.players.size > 0) {
if (typeof state.activeMode.checkMatchEnd === 'function') {
Expand Down Expand Up @@ -205,6 +214,8 @@ export function startGameLoop() {
};
}),
hearts: state.heartField ? state.heartField.list() : [],
// Feeds the task panel on every phone and the task line on the map screens.
objective: state.objectives ? state.objectives.getStatePatch() : null,
// durationMs = warmup + survive so the on-screen clock starts at the full winDurationMs.
match: state.matchActive
? {
Expand Down
2 changes: 2 additions & 0 deletions lg_rpg_server/src/lib/cheerleader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const EVENT_MAPPERS = {
player_low_health: (d) => `${d.name} is low on health (${d.hp} left)`,
player_died: (d) => `${d.name} went down`,
boss_spawned: () => 'the dragon boss just appeared — slaying it wins the match',
objective_started: (d) => `a new task is live: ${d.label}`,
objective_won: (d) => `${d.names} completed the task "${d.label}" and is picking a power-up`,
match_won: (d) => {
if (!d.scores) return 'the squad survived and won the match';
const score = `Blue ${d.scores.teamA ?? 0} - Red ${d.scores.teamB ?? 0}`;
Expand Down
5 changes: 5 additions & 0 deletions lg_rpg_server/src/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export function endMatch(reason = 'all-dead', result = null) {
state.heartField.stop();
state.heartField = null;
}
if (state.objectives) {
state.objectives.stop();
state.objectives = null;
}
state.phase = GAME_PHASES.LOBBY;
// Carry health through so the final board can show who was still standing instead of assuming full HP.
const results = Array.from(state.players.values())
Expand Down Expand Up @@ -177,6 +181,7 @@ export function removePlayer(playerId, socketId) {
state.activeMode = null;
state.matchActive = false;
if (state.heartField) { state.heartField.stop(); state.heartField = null; }
if (state.objectives) { state.objectives.stop(); state.objectives = null; }
if (state.cheerleader) { state.cheerleader.stop(); state.cheerleader = null; }
}

Expand Down
38 changes: 12 additions & 26 deletions lg_rpg_server/src/net/socket-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CHARACTER_KITS,
CHARACTER_MELEE,
DEFAULT_CHARACTER,
ENEMY_SPAWN,
GAME_MODES,
GAME_PHASES,
HEALTH_BY_ID,
Expand Down Expand Up @@ -41,6 +42,7 @@ import {
} from '../cheerleader-bridge.js';
import { loadMap } from '../lib/map_loader.js';
import { HeartField } from '../lib/hearts.js';
import { ObjectiveDirector } from '../lib/objectives.js';
import { createCheerleader } from '../lib/cheerleader/index.js';

export function registerSocketHandlers() {
Expand Down Expand Up @@ -101,8 +103,7 @@ export function registerSocketHandlers() {
lastAttackAt: existing?.lastAttackAt ?? 0,
team: existing?.team ?? null,
character: existing?.character ?? DEFAULT_CHARACTER,
loadout: existing?.loadout ?? [], // equipped power-up/health item ids
powerupCooldowns: existing?.powerupCooldowns ?? {},
loadout: existing?.loadout ?? [], // the four items an objective reward can be picked from
// Last movement direction, so ranged shots fire where the player faces.
facingX: existing?.facingX ?? 1,
facingY: existing?.facingY ?? 0,
Expand Down Expand Up @@ -260,33 +261,11 @@ export function registerSocketHandlers() {
);
});

// Activates a loadout item (buff or potion); must be equipped and off cooldown.
// Picks an objective reward: power-ups can't be tapped at will in either mode any more, so this only works while the player has a reward window open (the item then fires at once, for free) and anyone without one is ignored.
socket.on(SOCKET_EVENTS.ACTIVATE_POWERUP, (data = {}) => {
const player = state.players.get(state.socketPlayers.get(socket.id));
if (!player || player.dead) return;
const type = String(data.type || '');
if (!Array.isArray(player.loadout) || !player.loadout.includes(type)) return;

const powerup = POWERUP_BY_ID[type];
const health = HEALTH_BY_ID[type];
if (!powerup && !health) return;

const now = Date.now();
const cooldownMs = powerup?.cooldownMs ?? health?.cooldownMs ?? 0;
player.powerupCooldowns = player.powerupCooldowns || {};
if (now - (player.powerupCooldowns[type] || 0) < cooldownMs) return;

if (powerup) {
// Absolute expiries, read by the game loop and the MOVE handler.
if (type === 'speed') player.speedBoostUntil = now + powerup.durationMs;
else if (type === 'shield') player.shieldUntil = now + powerup.durationMs;
else if (type === 'reflect') player.reflectUntil = now + powerup.durationMs;
else if (type === 'power') player.powerUntil = now + powerup.durationMs;
} else {
player.health = Math.min(player.maxHealth, player.health + health.heal);
if (player.health > player.maxHealth * 0.3) player.lowHealthSignaled = false;
}
player.powerupCooldowns[type] = now;
state.objectives?.pickReward(player, String(data.type || ''));
});

// Lets a player pick their character from the lobby (its specials load free).
Expand Down Expand Up @@ -450,6 +429,13 @@ export function registerSocketHandlers() {
state.heartField = state.activeMode ? new HeartField(state.currentMap) : null;
state.heartField?.start();

if (state.objectives) state.objectives.stop();
state.objectives = state.activeMode
? new ObjectiveDirector(state.selectedMode, state.currentMap, state.activeMode)
: null;
// Zombie mode waits for the enemy warmup so the first task isn't run on an empty map; PvP starts live, so tasks begin right away.
state.objectives?.start(state.selectedMode === GAME_MODES.ZOMBIE ? ENEMY_SPAWN.warmupMs : 0);

if (state.cheerleader) state.cheerleader.stop();
state.cheerleader = createCheerleader({
drain: drainGameEvents,
Expand Down