diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts new file mode 100644 index 0000000..bb08226 --- /dev/null +++ b/frontend/src/scripts/feedback-hud.ts @@ -0,0 +1,98 @@ +import { settings } from 'shared'; +import { Pointer } from './helper/pointer'; + +// Lightweight, self-managing combat-feedback overlay for the local player: +// hitmarkers, a personal killfeed, multi-kill callouts and a points popup. It +// owns a single fixed, click-through root under and auto-expires every +// element it creates, so callers (CharacterView remote-call handlers) just fire +// and forget — no wiring through the game loop. +export abstract class FeedbackHud { + private static root?: HTMLElement; + private static killfeed?: HTMLElement; + + private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } { + if (!this.root || !this.killfeed) { + this.root = document.createElement('div'); + this.root.className = 'feedback-hud'; + + this.killfeed = document.createElement('div'); + this.killfeed.className = 'killfeed'; + this.root.appendChild(this.killfeed); + + document.body.appendChild(this.root); + } + return { root: this.root, killfeed: this.killfeed }; + } + + // Where transient marks/popups appear: the cursor on desktop, screen centre on + // touch (where the local character fires along its facing). + private static focusPoint(): { x: number; y: number } { + const cursor = Pointer.getDisplayPosition(); + if (cursor) { + return { x: cursor.x, y: cursor.y }; + } + return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + } + + private static addTransient(element: HTMLElement, lifetimeMs: number) { + const { root } = this.ensureRoot(); + root.appendChild(element); + setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); + } + + // A non-lethal hit landed: a quick crosshair tick at the focus point. + public static hitMarker() { + const { x, y } = this.focusPoint(); + const marker = document.createElement('div'); + marker.className = 'hitmarker'; + marker.style.left = `${x}px`; + marker.style.top = `${y}px`; + this.addTransient(marker, 250); + } + + // A kill was confirmed: killfeed line, points popup and (for streaks) a callout. + public static killConfirmed(victimName?: string, streak = 1) { + const { killfeed } = this.ensureRoot(); + + const entry = document.createElement('div'); + entry.className = 'kill-entry'; + entry.innerHTML = `Eliminated ${this.escape(victimName ?? 'enemy')}`; + killfeed.insertBefore(entry, killfeed.firstChild); + setTimeout(() => entry.parentElement?.removeChild(entry), 4500); + + const { x, y } = this.focusPoint(); + const popup = document.createElement('div'); + popup.className = 'kill-popup'; + popup.innerHTML = `+${settings.playerKillPoint} +${settings.playerKillHealthReward}❤`; + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + this.addTransient(popup, 1200); + + const callout = this.streakName(streak); + if (callout) { + const el = document.createElement('div'); + el.className = 'streak-callout'; + el.innerText = callout; + this.addTransient(el, 1400); + } + } + + private static streakName(streak: number): string | undefined { + switch (streak) { + case 2: + return 'Double Kill!'; + case 3: + return 'Triple Kill!'; + case 4: + return 'Quad Kill!'; + default: + return streak >= 5 ? 'Rampage!' : undefined; + } + } + + private static escape(text: string): string { + const div = document.createElement('div'); + div.innerText = text; + return div.innerHTML; + } +} diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts new file mode 100644 index 0000000..115d874 --- /dev/null +++ b/frontend/src/scripts/scoreboard.ts @@ -0,0 +1,66 @@ +import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared'; + +// Centred tug-of-war territory bar. The two fills meet at the bar's centre and +// grow outward by each team's share of the score limit, so the meeting point +// reads as the lead at a glance. Numeric scores are overlaid (the leader's is +// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so +// the Game just appends `element` to the overlay and feeds it `update()`. +export class Scoreboard { + public readonly element = document.createElement('div'); + + private readonly declaFill = document.createElement('div'); + private readonly redFill = document.createElement('div'); + private readonly declaScore = document.createElement('span'); + private readonly redScore = document.createElement('span'); + private readonly youMarker = document.createElement('div'); + + constructor() { + this.element.className = 'planet-progress'; + + this.declaFill.className = 'fill decla'; + this.redFill.className = 'fill red'; + + this.declaScore.className = 'score decla'; + this.redScore.className = 'score red'; + + this.youMarker.className = 'you-marker'; + this.youMarker.innerText = 'YOU'; + + this.element.append( + this.declaFill, + this.redFill, + this.declaScore, + this.redScore, + this.youMarker, + ); + } + + public update( + { declaCount, redCount, limit }: UpdateGameState, + localTeam: CharacterTeam | undefined, + ) { + const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings; + const fraction = (count: number) => + Math.max( + count > 0 ? scoreboardMinFillPercent : 0, + clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent, + ); + + this.declaFill.style.width = fraction(declaCount) + '%'; + this.redFill.style.width = fraction(redCount) + '%'; + + this.declaScore.innerText = String(Math.round(declaCount)); + this.redScore.innerText = String(Math.round(redCount)); + + this.declaScore.classList.toggle('leading', declaCount > redCount); + this.redScore.classList.toggle('leading', redCount > declaCount); + + if (localTeam === CharacterTeam.decla || localTeam === CharacterTeam.red) { + this.youMarker.style.display = ''; + this.youMarker.classList.toggle('decla', localTeam === CharacterTeam.decla); + this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red); + } else { + this.youMarker.style.display = 'none'; + } + } +} diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index e35dc1d..68bccb8 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -14,6 +14,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { uniform float planetLengths[PLANET_COUNT]; uniform float planetRandoms[PLANET_COUNT]; uniform float planetColorMixQ[PLANET_COUNT]; + uniform float planetRotations[PLANET_COUNT]; uniform sampler2D noiseTexture; @@ -52,12 +53,20 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { vec2 center = planetCenters[j]; float l = planetLengths[j]; float randomOffset = planetRandoms[j]; + float rotation = planetRotations[j]; vec2 targetCenterDelta = target - center; float targetDistance = length(targetCenterDelta); vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0); + + float cr = cos(rotation); + float sr = sin(rotation); + vec2 rotatedTangent = vec2( + cr * targetTangent.x - sr * targetTangent.y, + sr * targetTangent.x + cr * targetTangent.y + ); vec2 noisyTarget = target - ( targetTangent * planetTerrain(vec2( - l * abs(atan(targetTangent.y, targetTangent.x)), + l * abs(atan(rotatedTangent.y, rotatedTangent.x)), randomOffset )) / 12.0 ); @@ -89,8 +98,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString( - settings.redPlanetColor, - )}, planetColorMixQ[j]); + settings.redPlanetColor, + )}, planetColorMixQ[j]); } } @@ -105,6 +114,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { center: 'planetCenters', vertices: 'planetVertices', colorMixQ: 'planetColorMixQ', + rotation: 'planetRotations', }, uniformCountMacroName: `PLANET_COUNT`, shaderCombinationSteps: [0, 1, 2, 3], @@ -112,6 +122,9 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { }; public randomOffset = 0; + // Radians the surface noise is rotated by; advanced over time by PlanetView so + // the planet's textured surface slowly spins. + public rotation = 0; constructor( public vertices: Array, @@ -142,6 +155,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { length, random: this.randomOffset, colorMixQ: this.colorMixQ, + rotation: this.rotation, }; } diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index f772049..81607ad 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -52,7 +52,7 @@ export abstract class SoundHandler { return sound; } - public static play(sound: Sounds, volume = 1) { + public static play(sound: Sounds, volume = 1, playbackRate = 1) { if (!this.initialized || !OptionsHandler.options.soundsEnabled) { return; } @@ -61,7 +61,8 @@ export abstract class SoundHandler { this.sounds[sound].currentTime > 0 ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) : this.sounds[sound]; - audio.volume = volume; + audio.volume = Math.max(0, Math.min(1, volume)); + audio.playbackRate = playbackRate; audio.play(); } diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts new file mode 100644 index 0000000..823ef40 --- /dev/null +++ b/frontend/src/scripts/tutorial.ts @@ -0,0 +1,105 @@ +import { vec2 } from 'gl-matrix'; +import { + CharacterTeam, + CommandExecutors, + CommandReceiver, + Id, + MoveActionCommand, + PrimaryActionCommand, +} from 'shared'; +import { GameObjectContainer } from './objects/game-object-container'; +import { PlanetView } from './objects/types/planet-view'; + +type StageTrigger = 'move' | 'shoot' | 'capture'; + +const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [ + { hint: 'WASD / drag to walk', clearsOn: 'move' }, + { hint: 'Click / tap to shoot', clearsOn: 'shoot' }, + { hint: 'Stand on a planet to capture it', clearsOn: 'capture' }, +]; + +const captureProgressThreshold = 0.05; + +const standingSlack = 200; + +export class Tutorial extends CommandReceiver { + private stage = 0; + private element = document.createElement('div'); + + private characterId?: Id; + + private standingPlanet?: PlanetView; + private standingBaseline = 0; + + protected commandExecutors: CommandExecutors = { + [MoveActionCommand.type]: (c: MoveActionCommand) => { + if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) { + this.advance(); + } + }, + [PrimaryActionCommand.type]: () => { + if (this.clearsOn() === 'shoot') { + this.advance(); + } + }, + }; + + constructor(overlay: HTMLElement) { + super(); + this.element.className = 'tutorial-hint'; + this.element.innerText = stages[0].hint; + overlay.appendChild(this.element); + } + + private clearsOn(): StageTrigger | undefined { + return stages[this.stage]?.clearsOn; + } + + private advance() { + this.setStage(this.stage + 1); + } + + public step(gameObjects: GameObjectContainer) { + if (this.stage >= stages.length) { + return; + } + + const player = gameObjects.player; + if (!player) { + return; + } + + if (this.characterId === undefined) { + this.characterId = player.id; + } else if (player.id !== this.characterId) { + this.finish(); + return; + } + + if (this.clearsOn() === 'capture') { + const standing = gameObjects.planets.find( + (p) => vec2.distance(p.center, player.bodyCenter) < p.radius + standingSlack, + ); + if (standing !== this.standingPlanet) { + this.standingPlanet = standing; + this.standingBaseline = standing?.ownership ?? 0; + } + if (standing) { + const drift = standing.ownership - this.standingBaseline; + const progress = player.team === CharacterTeam.decla ? -drift : drift; + if (progress > captureProgressThreshold) { + this.finish(); + } + } + } + } + + private finish() { + this.setStage(stages.length); + } + + private setStage(stage: number) { + this.stage = stage; + this.element.innerText = stages[stage]?.hint ?? ''; + } +}