Even more improvements

This commit is contained in:
Andras Schmelczer 2026-06-11 21:43:49 +01:00
parent e3c44f775b
commit ec579650d7
5 changed files with 724 additions and 109 deletions

View file

@ -1,4 +1,5 @@
import { vec2 } from 'gl-matrix';
import { CircleLight, Renderer } from 'sdf-2d';
import {
Circle,
@ -6,6 +7,9 @@ import {
CharacterBase,
CharacterTeam,
settings,
clamp,
clamp01,
mix,
CommandExecutors,
UpdatePropertyCommand,
} from 'shared';
@ -13,15 +17,36 @@ import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step';
import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator';
import { BlobShape } from '../../shapes/blob-shape';
import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator';
import { Pointer } from '../../helper/pointer';
import { CharacterShape } from '../../shapes/character-shape';
import { SoundHandler, Sounds } from '../../sound-handler';
import { VibrationHandler } from '../../vibration-handler';
import { FeedbackHud } from '../../feedback-hud';
const muzzleFlashDecaySeconds = 0.12;
const hitFlashDecaySeconds = 0.15;
const killIcon =
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M6.2,2.44L18.1,14.34L20.22,12.22L21.63,13.63L19.16,16.1L22.34,19.28C22.73,19.67 22.73,20.3 22.34,20.69L21.63,21.4C21.24,21.79 20.61,21.79 20.22,21.4L17,18.23L14.56,20.7L13.15,19.29L15.27,17.17L3.37,5.27V2.44H6.2M15.89,10L20.63,5.26V2.44H17.8L13.06,7.18L15.89,10M10.94,15L8.11,12.13L5.9,14.34L3.78,12.22L2.37,13.63L4.84,16.1L1.66,19.28C1.27,19.67 1.27,20.3 1.66,20.69L2.37,21.4C2.76,21.79 3.39,21.79 3.78,21.4L7,18.23L9.42,20.7L10.83,19.29L8.71,17.17L10.94,15Z"/></svg>';
const deathIcon =
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M12,2A9,9 0 0,0 3,11C3,14.03 4.53,16.82 7,18.47V22H9V19H11V22H13V19H15V18.46C17.47,16.81 19,14.03 19,11A9,9 0 0,0 12,2M8,11A2,2 0 0,1 10,13A2,2 0 0,1 8,15A2,2 0 0,1 6,13A2,2 0 0,1 8,11M16,11A2,2 0 0,1 18,13A2,2 0 0,1 16,15A2,2 0 0,1 14,13A2,2 0 0,1 16,11Z"/></svg>';
export class CharacterView extends CharacterBase {
private shape: BlobShape;
private shape: CharacterShape;
private muzzleFlash: CircleLight;
private muzzleFlashIntensity = 0;
private hitFlashIntensity = 0;
private strength = settings.playerMaxStrength;
private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength);
private nameElement: HTMLElement = document.createElement('div');
private statsElement: HTMLElement = document.createElement('div');
private killCountElement: HTMLElement = document.createElement('span');
private deathCountElement: HTMLElement = document.createElement('span');
private healthElement: HTMLElement = document.createElement('div');
private chargeElement: HTMLElement = document.createElement('div');
public isMainCharacter = false;
@ -48,7 +73,12 @@ export class CharacterView extends CharacterBase {
rightFoot?: Circle,
) {
super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot);
this.shape = new BlobShape(settings.colorIndices[team]);
this.shape = new CharacterShape(settings.colorIndices[team]);
this.muzzleFlash = new CircleLight(
vec2.clone(this.head!.center),
settings.paletteDim[settings.colorIndices[team]],
0,
);
this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!);
this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!);
@ -56,7 +86,24 @@ export class CharacterView extends CharacterBase {
this.nameElement.className = 'player-tag ' + this.team;
this.nameElement.innerText = this.name;
this.healthElement.className = 'health';
this.chargeElement.className = 'charge';
this.statsElement.className = 'stats';
this.killCountElement.className = 'value';
this.deathCountElement.className = 'value';
const killStat = document.createElement('span');
killStat.className = 'stat kills';
killStat.innerHTML = killIcon;
killStat.appendChild(this.killCountElement);
const deathStat = document.createElement('span');
deathStat.className = 'stat deaths';
deathStat.innerHTML = deathIcon;
deathStat.appendChild(this.deathCountElement);
this.statsElement.append(killStat, deathStat);
this.nameElement.appendChild(this.healthElement);
this.nameElement.appendChild(this.chargeElement);
this.nameElement.appendChild(this.statsElement);
}
@ -64,6 +111,29 @@ export class CharacterView extends CharacterBase {
return this.head!.center;
}
public get bodyCenter(): vec2 {
const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center);
vec2.add(center, center, this.rightFoot!.center);
return vec2.scale(center, center, 1 / 3);
}
public get facingDirection(): vec2 {
const footAverage = vec2.add(
vec2.create(),
this.leftFoot!.center,
this.rightFoot!.center,
);
vec2.scale(footAverage, footAverage, 0.5);
const forward = vec2.subtract(footAverage, this.head!.center, footAverage);
return vec2.length(forward) > 0
? vec2.normalize(forward, forward)
: vec2.fromValues(0, 1);
}
public get strengthFraction(): number {
return clamp01(this.strength / settings.playerMaxStrength);
}
private updateProperty({
propertyKey,
propertyValue,
@ -78,18 +148,25 @@ export class CharacterView extends CharacterBase {
if (propertyKey === 'rightFoot') {
this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange);
}
if (propertyKey === 'strength') {
this.strengthExtrapolator.addFrame(propertyValue, rateOfChange);
}
}
public setHealth(health: number) {
const previousHealth = this.health;
const damage = this.health - health;
super.setHealth(health);
SoundHandler.play(
Sounds.hit,
(0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength,
);
if (this.isMainCharacter) {
VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4));
if (damage > 0) {
SoundHandler.play(
Sounds.hit,
Math.min(1, (0.8 * damage) / settings.playerMaxStrength),
);
this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength);
if (this.isMainCharacter) {
VibrationHandler.vibrate(Math.min(200, damage * 4));
}
}
}
@ -99,14 +176,58 @@ export class CharacterView extends CharacterBase {
}
}
public onHitConfirmed() {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.click, 0.4, 1.7);
FeedbackHud.hitMarker();
}
public onKillConfirmed(victimName?: string, streak = 1) {
if (!this.isMainCharacter) {
return;
}
SoundHandler.play(Sounds.click, 1, 0.7);
VibrationHandler.vibrate(60);
FeedbackHud.killConfirmed(victimName, streak);
}
private step({ deltaTimeInSeconds }: StepCommand): void {
this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds);
this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds);
this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds);
this.strength = clamp(
this.strengthExtrapolator.getValue(deltaTimeInSeconds),
0,
settings.playerMaxStrength,
);
if (this.muzzleFlashIntensity > 0) {
this.muzzleFlashIntensity = Math.max(
0,
this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds,
);
this.muzzleFlash.center = this.head!.center;
this.muzzleFlash.intensity = this.muzzleFlashIntensity;
}
if (this.hitFlashIntensity > 0) {
this.hitFlashIntensity = Math.max(
0,
this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds,
);
}
}
public onShoot(strength: number) {
SoundHandler.play(Sounds.shoot, (0.6 * strength) / settings.playerMaxStrength);
const q = clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);
SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q));
this.muzzleFlashIntensity = mix(0.35, 1, q);
}
private beforeDestroy(): void {
@ -127,15 +248,34 @@ export class CharacterView extends CharacterBase {
this.healthElement.style.width =
(50 * this.health) / settings.playerMaxHealth + 'px';
this.statsElement.innerText = this.getStatsText();
this.chargeElement.style.width =
(50 * this.strength) / settings.playerMaxStrength + 'px';
this.killCountElement.innerText = String(this.killCount);
this.deathCountElement.innerText = String(this.deathCount);
}
this.shape.hitFlash = this.hitFlashIntensity;
this.shape.gazeTarget = this.calculateGazeTarget(renderer);
this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]);
renderer.addDrawable(this.shape);
if (this.muzzleFlashIntensity > 0) {
renderer.addDrawable(this.muzzleFlash);
}
}
private getStatsText(): string {
return `${this.killCount}⚔/${this.deathCount}`;
private calculateGazeTarget(renderer: Renderer): vec2 {
const cursor = Pointer.getDisplayPosition();
if (this.isMainCharacter && cursor) {
return renderer.displayToWorldCoordinates(cursor);
}
return vec2.scaleAndAdd(
vec2.create(),
this.head!.center,
this.facingDirection,
this.head!.radius * 8,
);
}
private calculateTextPosition(): vec2 {

View file

@ -1,21 +1,49 @@
import { vec2 } from 'gl-matrix';
import { Id, Random, PlanetBase, UpdatePropertyCommand, CommandExecutors } from 'shared';
import { vec2, vec3 } from 'gl-matrix';
import { CircleLight } from 'sdf-2d';
import {
Id,
Random,
PlanetBase,
UpdatePropertyCommand,
CommandExecutors,
CharacterTeam,
settings,
} from 'shared';
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step';
import { PlanetShape } from '../../shapes/planet-shape';
type FallingPoint = {
velocity: vec2;
position: vec2;
element: HTMLElement;
addedToOverlay: boolean;
timeToLive: number;
};
const fallingPointLifetimeMs = 2000;
// Global budget for simultaneously-lit capture flares, so a clustered wave of
// captures can never white out the SDF exposure — excess flips still pulse the
// ring and toast, they just skip the extra light. The acquire/release pair keeps
// the count in one place instead of being hand-maintained at every call site.
abstract class FlareBudget {
private static active = 0;
public static tryAcquire(): boolean {
if (FlareBudget.active >= settings.maxConcurrentFlipFlares) {
return false;
}
FlareBudget.active++;
return true;
}
public static release(): void {
FlareBudget.active = Math.max(0, FlareBudget.active - 1);
}
}
export class PlanetView extends PlanetBase {
private shape: PlanetShape;
private ownershipProgress: HTMLElement;
private readonly rotationSpeed: number;
private flareLight?: CircleLight;
private flareIntensity = 0;
private holdsFlareSlot = false;
protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this),
@ -27,45 +55,69 @@ export class PlanetView extends PlanetBase {
constructor(id: Id, vertices: Array<vec2>, ownership: number) {
super(id, vertices);
this.shape = new PlanetShape(vertices, ownership);
(this.shape as any).randomOffset = Random.getRandom();
this.shape.randomOffset = Random.getRandom();
this.rotationSpeed =
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
this.ownershipProgress = document.createElement('div');
this.ownershipProgress.className = 'ownership';
}
private step({ deltaTimeInSeconds }: StepCommand): void {
this.shape.randomOffset += deltaTimeInSeconds / 4;
this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed;
this.shape.colorMixQ = this.ownership;
this.generatedPointElements.forEach((p) => {
vec2.add(
p.velocity,
p.velocity,
vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds),
if (this.flareIntensity > 0) {
this.flareIntensity = Math.max(
0,
this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds,
);
vec2.add(
p.position,
p.position,
vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds),
);
if (this.flareLight) {
this.flareLight.intensity =
settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity;
}
p.timeToLive -= deltaTimeInSeconds;
});
if (this.flareIntensity === 0) {
this.releaseFlareSlot();
}
}
}
private generatedPointElements: Array<FallingPoint> = [];
private releaseFlareSlot(): void {
if (this.holdsFlareSlot) {
this.holdsFlareSlot = false;
FlareBudget.release();
}
}
private lastGeneratedPoint?: number;
public generatedPoints(value: number) {
this.lastGeneratedPoint = value;
}
public onFlipped(team: CharacterTeam): void {
const color = settings.palette[settings.colorIndices[team]];
if (!this.flareLight) {
this.flareLight = new CircleLight(vec2.clone(this.center), vec3.clone(color), 0);
} else {
this.flareLight.color = vec3.clone(color);
}
if (!this.holdsFlareSlot) {
if (!FlareBudget.tryAcquire()) {
return;
}
this.holdsFlareSlot = true;
}
this.flareIntensity = 1;
}
private beforeDestroy(): void {
this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress);
this.generatedPointElements.forEach((p) =>
p.element.parentElement?.removeChild(p.element),
);
this.releaseFlareSlot();
}
private updateProperty({ propertyValue }: UpdatePropertyCommand): void {
@ -80,26 +132,6 @@ export class PlanetView extends PlanetBase {
const screenPosition = renderer.worldToDisplayCoordinates(this.center);
this.generatedPointElements.forEach((p) => {
if (!p.addedToOverlay) {
overlay.appendChild(p.element);
}
p.element.style.transform = `translateX(${
screenPosition.x + p.position.x
}px) translateY(${screenPosition.y + p.position.y}px)`;
if (p.timeToLive <= 0) {
p.element.parentElement?.removeChild(p.element);
} else {
p.element.style.opacity = Math.min(1, p.timeToLive).toString();
}
});
this.generatedPointElements = this.generatedPointElements.filter(
(p) => p.timeToLive > 0,
);
this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`;
this.ownershipProgress.style.background = this.getGradient();
@ -107,19 +139,23 @@ export class PlanetView extends PlanetBase {
const element = document.createElement('div');
element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red');
element.innerText = '+' + this.lastGeneratedPoint;
this.generatedPointElements.push({
element,
addedToOverlay: false,
timeToLive: Random.getRandomInRange(2, 3),
position: vec2.create(),
velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0),
});
element.style.left = `${screenPosition.x}px`;
element.style.top = `${screenPosition.y}px`;
overlay.appendChild(element);
setTimeout(
() => element.parentElement?.removeChild(element),
fallingPointLifetimeMs,
);
this.lastGeneratedPoint = undefined;
}
}
renderer.addDrawable(this.shape);
if (this.flareIntensity > 0 && this.flareLight) {
renderer.addDrawable(this.flareLight);
}
}
private getGradient(): string {