From 73f5f45322d263186974418be707e9d81c371fee Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 15 Jun 2026 08:10:54 +0100 Subject: [PATCH] Add minimap --- backend/src/players/player.ts | 46 ++----- frontend/src/main.scss | 32 ++--- frontend/src/scripts/game.ts | 76 ++--------- frontend/src/scripts/minimap.ts | 126 ++++++++++++++++++ .../scripts/objects/game-object-container.ts | 11 ++ ...player-directions.ts => update-minimap.ts} | 12 +- shared/src/main.ts | 3 +- 7 files changed, 184 insertions(+), 122 deletions(-) create mode 100644 frontend/src/scripts/minimap.ts rename shared/src/commands/types/{update-other-player-directions.ts => update-minimap.ts} (60%) diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index ca563fc..5081f48 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -13,10 +13,10 @@ import { settings, PlayerInformation, CharacterTeam, - UpdateOtherPlayerDirections, + UpdateMinimap, GameObject, Command, - OtherPlayerDirection, + MinimapPlayer, RemoteCallsForObject, RemoteCallsForObjects, ServerAnnouncement, @@ -29,7 +29,6 @@ import { import { Socket } from 'socket.io'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { PhysicalContainer } from '../physics/containers/physical-container'; -import { CharacterPhysical } from '../objects/character-physical'; import { PlayerContainer } from './player-container'; import { PlayerBase } from './player-base'; @@ -142,7 +141,7 @@ export class Player extends PlayerBase { this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); } - this.queueCommandSend(new UpdateOtherPlayerDirections(this.getOtherPlayers())); + this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); this.queueCommandSend( new PropertyUpdatesForObjects( @@ -167,36 +166,15 @@ export class Player extends PlayerBase { } } - private getOtherPlayers(): Array { - if (!this.character) { - return []; - } - - const viewArea = calculateViewArea(this.center, this.aspectRatio, 0.9); - const bb = new BoundingBox(); - bb.topLeft = viewArea.topLeft; - bb.size = viewArea.size; - - const playersInViewArea = this.objectContainer - .findIntersecting(bb) - .map((o) => o.gameObject) - .filter((g) => g instanceof CharacterPhysical); - - const otherPlayers = this.playerContainer.players.filter( - (p) => p.character?.isAlive && playersInViewArea.indexOf(p.character!) < 0, - ); - - return otherPlayers.map( - (p) => - new OtherPlayerDirection( - p.character!.id, - vec2.normalize( - vec2.create(), - vec2.subtract(vec2.create(), p.character!.center, this.character!.center), - ), - p.team, - ), - ); + // Every living player except this one, reported by absolute world position so + // the client can plot the whole circular arena on its minimap. + private getMinimapPlayers(): Array { + return this.playerContainer.players + .filter((p) => p !== this && p.character?.isAlive) + .map( + (p) => + new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team), + ); } private commandsToBeSent: Array = []; diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 5dcbb56..c8aab8f 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -231,25 +231,27 @@ body { } } - .other-player-arrow { - transition: transform 150ms; - - @include square($large-icon); + // Top-down radar of the whole arena (see Minimap). The circular border is + // the world boundary; dots are painted onto the canvas. + .minimap { + top: $small-padding; + left: $small-padding; + @include square(132px); + // On narrow screens the centred scoreboard bar reaches close to the left + // edge, so drop the minimap below it to avoid a corner overlap. @media (max-width: $breakpoint) { - @include square($small-icon); + @include square(96px); + top: calc(#{$small-padding} + 32px); } - mask-image: url('../static/chevron.svg'); - mask-size: contain; - - &.blue { - background-color: $bright-blue; - } - - &.red { - background-color: $bright-red; - } + border-radius: 1000px; + background-color: rgba(0, 0, 0, 0.35); + box-shadow: + inset 0 0 3px 0 rgba(0, 0, 0, 0.4), + 0 0 10px rgba(0, 0, 0, 0.3); + border: $border-width solid rgba(255, 255, 255, 0.25); + z-index: 100; } // Off-screen pointer to the keystone "Heart", tinted by its current owner. diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index cad62cd..a212d5a 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -11,8 +11,7 @@ import { deserialize, TransportEvents, SetAspectRatioActionCommand, - UpdateOtherPlayerDirections, - clamp, + UpdateMinimap, UpdateGameState, GameEndCommand, ServerAnnouncement, @@ -39,6 +38,7 @@ import { serverTimeline } from './helper/server-timeline'; import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; +import { Minimap } from './minimap'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); @@ -54,8 +54,8 @@ export class Game extends CommandReceiver { private touchListener: TouchListener; private scoreboard = new Scoreboard(); + private minimap = new Minimap(); private announcementText = document.createElement('h2'); - private arrows: { [id: number]: HTMLElement } = {}; private keystoneArrow?: HTMLElement; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; @@ -82,11 +82,12 @@ export class Game extends CommandReceiver { localCharacterPredictor.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; - this.arrows = {}; this.keystoneArrow = undefined; + this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; this.overlay.appendChild(this.scoreboard.element); + this.overlay.appendChild(this.minimap.element); this.announcementText.innerText = ''; this.timeScaling = 1; this.overlay.appendChild(this.announcementText); @@ -160,67 +161,11 @@ export class Game extends CommandReceiver { c.lastLeapClientTimeMs, ), [GameEndCommand.type]: () => (this.isEnding = true), - [UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) => - (this.lastOtherPlayerDirections = c), + [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), [GameStartCommand.type]: this.initialize.bind(this), }; - private lastOtherPlayerDirections?: UpdateOtherPlayerDirections; - private handleOtherPlayerDirections(command: UpdateOtherPlayerDirections) { - command.otherPlayerDirections.forEach((d) => { - if (!(d.id! in this.arrows)) { - const element = document.createElement('div'); - this.arrows[d.id!] = element; - this.overlay.appendChild(element); - } - - const e = this.arrows[d.id!]; - const direction = d.direction; - const team = d.team; - const angle = Math.atan2(direction.y, direction.x); - e.className = 'other-player-arrow ' + team; - - if (!this.renderer) { - return; - } - - const width = this.renderer.canvasSize.x; - const height = this.renderer.canvasSize.y; - const aspectRatio = width / height; - const directionRatio = direction.x / direction.y; - - let deltaX: number, deltaY: number; - if (aspectRatio < Math.abs(directionRatio)) { - deltaX = (width / 2) * Math.sign(direction.x); - deltaY = deltaX / directionRatio; - } else { - deltaY = (height / 2) * Math.sign(direction.y); - deltaX = deltaY * directionRatio; - } - - const delta = vec2.fromValues(deltaX, deltaY); - const center = vec2.fromValues(width / 2, height / 2); - const p = vec2.add(center, center, delta); - const arrowPadding = 24; - vec2.set( - p, - clamp(p.x, arrowPadding, width - arrowPadding), - clamp(height - p.y, arrowPadding, height - arrowPadding), - ); - e.style.transform = `translateX(${p.x}px) translateY(${p.y - }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; - }); - - for (const id in this.arrows) { - if ( - Object.prototype.hasOwnProperty.call(this.arrows, id) && - command.otherPlayerDirections.find((v) => v.id?.toString() === id) === undefined - ) { - this.arrows[id].parentElement?.removeChild(this.arrows[id]); - delete this.arrows[id]; - } - } - } + private lastMinimap?: UpdateMinimap; public async start(): Promise { const noiseTexture = await renderNoise([256, 256], 2, 1); @@ -332,9 +277,10 @@ export class Game extends CommandReceiver { this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); } - if (this.lastOtherPlayerDirections) { - this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); - } + this.minimap.update( + this.gameObjects.localPlayerPosition, + this.lastMinimap?.players ?? [], + ); this.handleKeystoneArrow(); diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts new file mode 100644 index 0000000..64267e8 --- /dev/null +++ b/frontend/src/scripts/minimap.ts @@ -0,0 +1,126 @@ +import { vec2 } from 'gl-matrix'; +import { CharacterTeam, Id, settings } from 'shared'; + +export interface MinimapBlip { + id: Id; + position: vec2; + team: CharacterTeam; +} + +// Top-down radar of the whole circular arena, pinned to the top-left. The map's +// circular border is the world boundary (its radius maps to worldRadius), so the +// local player's bright dot reads as a true position in the arena and every other +// living player shows as a team-coloured dot. Owns its own , so the Game +// just appends `element` to the overlay and feeds it `update()` each frame. +// Replaces the off-screen chevrons that used to point at other players. +export class Minimap { + public readonly element = document.createElement('canvas'); + + private readonly ctx: CanvasRenderingContext2D; + private readonly colors: Record; + // World-space positions, smoothed per player so the 25 Hz snapshots glide + // rather than step between frames. + private readonly smoothed = new Map(); + private bufferSize = 0; + + constructor() { + this.element.className = 'minimap'; + this.ctx = this.element.getContext('2d')!; + + const theme = getComputedStyle(document.documentElement); + const read = (name: string, fallback: string) => + theme.getPropertyValue(name).trim() || fallback; + this.colors = { + [CharacterTeam.blue]: read('--bright-blue', '#4069a5'), + [CharacterTeam.red]: read('--bright-red', '#d15652'), + [CharacterTeam.neutral]: read('--bright-neutral', '#ccc'), + }; + } + + public update(localPosition: vec2 | undefined, players: Array) { + const size = this.element.clientWidth; + if (size === 0) { + return; // not laid out yet + } + this.syncBufferSize(size); + + const ctx = this.ctx; + ctx.clearRect(0, 0, size, size); + + const center = size / 2; + const innerRadius = center - 5; + const scale = innerRadius / settings.worldRadius; + + const toMap = (world: vec2): { x: number; y: number } => { + let dx = world.x * scale; + let dy = -world.y * scale; // world Y points up, canvas Y points down + const distance = Math.hypot(dx, dy); + if (distance > innerRadius) { + dx = (dx / distance) * innerRadius; + dy = (dy / distance) * innerRadius; + } + return { x: center + dx, y: center + dy }; + }; + + // Faint marker at the world's centre to aid orientation. + ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; + ctx.beginPath(); + ctx.arc(center, center, 1.5, 0, Math.PI * 2); + ctx.fill(); + + const present = new Set(); + for (const blip of players) { + present.add(blip.id); + let world = this.smoothed.get(blip.id); + if (world) { + vec2.lerp(world, world, blip.position, 0.3); + } else { + world = vec2.clone(blip.position); + this.smoothed.set(blip.id, world); + } + const { x, y } = toMap(world); + this.drawDot(x, y, 3, this.colors[blip.team]); + } + for (const id of this.smoothed.keys()) { + if (!present.has(id)) { + this.smoothed.delete(id); + } + } + + // The local player rides on top, drawn in white with a ring so "you" is + // unmistakable amongst the team-coloured dots. + if (localPosition) { + const { x, y } = toMap(localPosition); + this.drawDot(x, y, 3.5, '#ffffff'); + ctx.beginPath(); + ctx.arc(x, y, 6, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + private drawDot(x: number, y: number, radius: number, color: string) { + const ctx = this.ctx; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = radius * 2; + ctx.fill(); + ctx.shadowBlur = 0; + } + + private syncBufferSize(cssSize: number) { + const dpr = window.devicePixelRatio || 1; + const target = Math.round(cssSize * dpr); + if (this.bufferSize !== target) { + this.bufferSize = target; + this.element.width = target; + this.element.height = target; + } + // Setting the buffer size resets the transform, so (re)establish it every + // frame and draw in CSS pixels regardless of the device pixel ratio. + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } +} diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 81c1e2e..fa48f08 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -1,3 +1,4 @@ +import { vec2 } from 'gl-matrix'; import { Circle, Command, @@ -84,6 +85,16 @@ export class GameObjectContainer extends CommandReceiver { super(); } + // The local player's world position, but only while the body is alive. On + // death the server deletes the character object (yet `player` keeps pointing + // at the now-stale view), so gate on the object still being present — otherwise + // the minimap would pin the "you" dot at the death spot for the whole respawn. + public get localPlayerPosition(): vec2 | undefined { + return this.player && this.objects.has(this.player.id) + ? this.player.position + : undefined; + } + public get planets(): Array { const planets: Array = []; this.objects.forEach((o) => { diff --git a/shared/src/commands/types/update-other-player-directions.ts b/shared/src/commands/types/update-minimap.ts similarity index 60% rename from shared/src/commands/types/update-other-player-directions.ts rename to shared/src/commands/types/update-minimap.ts index a0d3223..a24e371 100644 --- a/shared/src/commands/types/update-other-player-directions.ts +++ b/shared/src/commands/types/update-minimap.ts @@ -5,25 +5,25 @@ import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; @serializable -export class OtherPlayerDirection { +export class MinimapPlayer { public constructor( public readonly id: Id, - public readonly direction: vec2, + public readonly position: vec2, public readonly team: CharacterTeam, ) {} public toArray(): Array { - return [this.id, this.direction, this.team]; + return [this.id, this.position, this.team]; } } @serializable -export class UpdateOtherPlayerDirections extends Command { - public constructor(public readonly otherPlayerDirections: Array) { +export class UpdateMinimap extends Command { + public constructor(public readonly players: Array) { super(); } public toArray(): Array { - return [this.otherPlayerDirections]; + return [this.players]; } } diff --git a/shared/src/main.ts b/shared/src/main.ts index d9af9ca..7adfcf6 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -7,10 +7,9 @@ export * from './commands/types/server-announcement'; export * from './commands/types/property-updates-for-objects'; export * from './commands/types/game-end'; export * from './commands/types/game-start'; -export * from './commands/types/update-other-player-directions'; +export * from './commands/types/update-minimap'; export * from './commands/types/update-game-state'; export * from './commands/command-receiver'; -export * from './commands/types/update-other-player-directions'; export * from './commands/command-executors'; export * from './commands/command-generator'; export * from './commands/types/actions/move-action';