Improve UI
This commit is contained in:
parent
793d9a81e8
commit
3848e460cd
5 changed files with 289 additions and 5 deletions
98
frontend/src/scripts/feedback-hud.ts
Normal file
98
frontend/src/scripts/feedback-hud.ts
Normal file
|
|
@ -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 <body> 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 <b>${this.escape(victimName ?? 'enemy')}</b>`;
|
||||||
|
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} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/src/scripts/scoreboard.ts
Normal file
66
frontend/src/scripts/scoreboard.ts
Normal file
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
uniform float planetLengths[PLANET_COUNT];
|
uniform float planetLengths[PLANET_COUNT];
|
||||||
uniform float planetRandoms[PLANET_COUNT];
|
uniform float planetRandoms[PLANET_COUNT];
|
||||||
uniform float planetColorMixQ[PLANET_COUNT];
|
uniform float planetColorMixQ[PLANET_COUNT];
|
||||||
|
uniform float planetRotations[PLANET_COUNT];
|
||||||
|
|
||||||
uniform sampler2D noiseTexture;
|
uniform sampler2D noiseTexture;
|
||||||
|
|
||||||
|
|
@ -52,12 +53,20 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
vec2 center = planetCenters[j];
|
vec2 center = planetCenters[j];
|
||||||
float l = planetLengths[j];
|
float l = planetLengths[j];
|
||||||
float randomOffset = planetRandoms[j];
|
float randomOffset = planetRandoms[j];
|
||||||
|
float rotation = planetRotations[j];
|
||||||
vec2 targetCenterDelta = target - center;
|
vec2 targetCenterDelta = target - center;
|
||||||
float targetDistance = length(targetCenterDelta);
|
float targetDistance = length(targetCenterDelta);
|
||||||
vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0);
|
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 - (
|
vec2 noisyTarget = target - (
|
||||||
targetTangent * planetTerrain(vec2(
|
targetTangent * planetTerrain(vec2(
|
||||||
l * abs(atan(targetTangent.y, targetTangent.x)),
|
l * abs(atan(rotatedTangent.y, rotatedTangent.x)),
|
||||||
randomOffset
|
randomOffset
|
||||||
)) / 12.0
|
)) / 12.0
|
||||||
);
|
);
|
||||||
|
|
@ -105,6 +114,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
center: 'planetCenters',
|
center: 'planetCenters',
|
||||||
vertices: 'planetVertices',
|
vertices: 'planetVertices',
|
||||||
colorMixQ: 'planetColorMixQ',
|
colorMixQ: 'planetColorMixQ',
|
||||||
|
rotation: 'planetRotations',
|
||||||
},
|
},
|
||||||
uniformCountMacroName: `PLANET_COUNT`,
|
uniformCountMacroName: `PLANET_COUNT`,
|
||||||
shaderCombinationSteps: [0, 1, 2, 3],
|
shaderCombinationSteps: [0, 1, 2, 3],
|
||||||
|
|
@ -112,6 +122,9 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
};
|
};
|
||||||
|
|
||||||
public randomOffset = 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(
|
constructor(
|
||||||
public vertices: Array<vec2>,
|
public vertices: Array<vec2>,
|
||||||
|
|
@ -142,6 +155,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
length,
|
length,
|
||||||
random: this.randomOffset,
|
random: this.randomOffset,
|
||||||
colorMixQ: this.colorMixQ,
|
colorMixQ: this.colorMixQ,
|
||||||
|
rotation: this.rotation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export abstract class SoundHandler {
|
||||||
return sound;
|
return sound;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static play(sound: Sounds, volume = 1) {
|
public static play(sound: Sounds, volume = 1, playbackRate = 1) {
|
||||||
if (!this.initialized || !OptionsHandler.options.soundsEnabled) {
|
if (!this.initialized || !OptionsHandler.options.soundsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,8 @@ export abstract class SoundHandler {
|
||||||
this.sounds[sound].currentTime > 0
|
this.sounds[sound].currentTime > 0
|
||||||
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
|
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
|
||||||
: this.sounds[sound];
|
: this.sounds[sound];
|
||||||
audio.volume = volume;
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
audio.playbackRate = playbackRate;
|
||||||
audio.play();
|
audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
105
frontend/src/scripts/tutorial.ts
Normal file
105
frontend/src/scripts/tutorial.ts
Normal file
|
|
@ -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 ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue