From 578d8f855be2b3217ca2e16a6d6eec0b1a5ad7ed Mon Sep 17 00:00:00 2001 From: Shailesh Kumar Shukla Date: Mon, 24 Aug 2026 11:50:20 +0530 Subject: [PATCH 1/3] feat(objectives): drive tasks from the game loop and gate power-ups --- lg_rpg_server/src/game-loop.js | 11 +++++++ lg_rpg_server/src/match.js | 5 ++++ lg_rpg_server/src/net/socket-handlers.js | 38 ++++++++---------------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/lg_rpg_server/src/game-loop.js b/lg_rpg_server/src/game-loop.js index 581ddd7..b44b41b 100644 --- a/lg_rpg_server/src/game-loop.js +++ b/lg_rpg_server/src/game-loop.js @@ -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; @@ -91,6 +93,7 @@ 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; @@ -98,6 +101,8 @@ export function startGameLoop() { 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 }); @@ -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') { @@ -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 ? { diff --git a/lg_rpg_server/src/match.js b/lg_rpg_server/src/match.js index beed5fe..ab7eba6 100644 --- a/lg_rpg_server/src/match.js +++ b/lg_rpg_server/src/match.js @@ -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()) @@ -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; } } diff --git a/lg_rpg_server/src/net/socket-handlers.js b/lg_rpg_server/src/net/socket-handlers.js index d3b64b2..ca052c0 100644 --- a/lg_rpg_server/src/net/socket-handlers.js +++ b/lg_rpg_server/src/net/socket-handlers.js @@ -2,6 +2,7 @@ import { CHARACTER_KITS, CHARACTER_MELEE, DEFAULT_CHARACTER, + ENEMY_SPAWN, GAME_MODES, GAME_PHASES, HEALTH_BY_ID, @@ -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() { @@ -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, @@ -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). @@ -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, From 62320edf85be504559e00b3758eaca07bc22b74a Mon Sep 17 00:00:00 2001 From: Shailesh Kumar Shukla Date: Mon, 24 Aug 2026 11:50:51 +0530 Subject: [PATCH 2/3] feat(cheerleader): call out task starts and wins --- lg_rpg_server/src/lib/cheerleader/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lg_rpg_server/src/lib/cheerleader/index.js b/lg_rpg_server/src/lib/cheerleader/index.js index 8fecfc7..d3f0a9a 100644 --- a/lg_rpg_server/src/lib/cheerleader/index.js +++ b/lg_rpg_server/src/lib/cheerleader/index.js @@ -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}`; From a079bdb6263f343c060c6743a03779ed0d575901 Mon Sep 17 00:00:00 2001 From: Shailesh Kumar Shukla Date: Mon, 24 Aug 2026 11:51:31 +0530 Subject: [PATCH 3/3] feat(screens): draw objective markers and the task line --- lg_rpg_server/public/game_logic.js | 7 +++- lg_rpg_server/public/scene/leaderboard.js | 15 ++++++- lg_rpg_server/public/scene/overlays.js | 49 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lg_rpg_server/public/game_logic.js b/lg_rpg_server/public/game_logic.js index 7eec384..67c63bb 100644 --- a/lg_rpg_server/public/game_logic.js +++ b/lg_rpg_server/public/game_logic.js @@ -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'; @@ -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(); @@ -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); } @@ -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 => { @@ -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); diff --git a/lg_rpg_server/public/scene/leaderboard.js b/lg_rpg_server/public/scene/leaderboard.js index af2ddd9..9df0027 100644 --- a/lg_rpg_server/public/scene/leaderboard.js +++ b/lg_rpg_server/public/scene/leaderboard.js @@ -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)]; @@ -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. diff --git a/lg_rpg_server/public/scene/overlays.js b/lg_rpg_server/public/scene/overlays.js index 3ae0ecd..25cb623 100644 --- a/lg_rpg_server/public/scene/overlays.js +++ b/lg_rpg_server/public/scene/overlays.js @@ -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();