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