Compare commits

...

4 commits

Author SHA1 Message Date
ec579650d7 Even more improvements 2026-06-11 21:43:49 +01:00
e3c44f775b More improvements 2026-06-11 21:33:08 +01:00
3848e460cd Improve UI 2026-06-11 21:02:06 +01:00
793d9a81e8 Improve gameplay 2026-06-11 20:26:44 +01:00
43 changed files with 2184 additions and 561 deletions

View file

@ -46,5 +46,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["node", "dist/main.js"] ENTRYPOINT ["node", "dist/main.js"]
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`. # Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`.
CMD ["--port", "3000"] CMD ["--port", "3000"]

View file

@ -6,11 +6,11 @@ import { PhysicalContainer } from './physics/containers/physical-container';
import { evaluateSdf } from './physics/functions/evaluate-sdf'; import { evaluateSdf } from './physics/functions/evaluate-sdf';
import { Physical } from './physics/physicals/physical'; import { Physical } from './physics/physicals/physical';
export const createWorld = (objectContainer: PhysicalContainer, worldRadius: number) => { export const createWorld = (objectContainer: PhysicalContainer) => {
const objects: Array<Physical> = []; const objects: Array<Physical> = [];
const lights: Array<Physical> = []; const lights: Array<Physical> = [];
for (let r = 0; r < worldRadius; r += settings.radiusSteps) { for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
const circumference = 2 * Math.PI * r; const circumference = 2 * Math.PI * r;
const stepCount = circumference * settings.objectsOnCircleLength; const stepCount = circumference * settings.objectsOnCircleLength;
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
@ -67,8 +67,29 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num
} }
} }
} }
console.info('Generated planet count', objects.length); console.info(`Generated ${objects.length} planets`);
console.info('Generated light count', lights.length); console.info(`Generated ${lights.length} light`);
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
// lamps to the owning team's colour when it flips. Lamps are already placed by
// proximity during world-gen, so the nearest planet is the one whose capture
// they should advertise. Distances use the planet SDF (negative inside), which
// is exactly the "closest planet" metric we want.
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
lights
.filter((l): l is LampPhysical => l instanceof LampPhysical)
.forEach((lamp) => {
let nearest: PlanetPhysical | undefined;
let nearestDistance = Infinity;
planets.forEach((planet) => {
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
});
nearest?.addLamp(lamp);
});
[...objects, ...lights].forEach((o) => objectContainer.addObject(o)); [...objects, ...lights].forEach((o) => objectContainer.addObject(o));
}; };

View file

@ -4,8 +4,7 @@ export const defaultOptions: Options = {
port: 3000, port: 3000,
name: 'Test server', name: 'Test server',
playerLimit: 16, playerLimit: 16,
npcCount: 16, npcCount: 8,
seed: Math.random(), seed: Math.random(),
scoreLimit: 1000, scoreLimit: 2500,
worldSize: 8000,
}; };

View file

@ -13,6 +13,7 @@ import {
Command, Command,
CommandReceiver, CommandReceiver,
CommandExecutors, CommandExecutors,
ServerAnnouncement,
} from 'shared'; } from 'shared';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { DeltaTimeCalculator } from './helper/delta-time-calculator';
@ -31,6 +32,7 @@ export class GameServer extends CommandReceiver {
private declaPoints = 0; private declaPoints = 0;
private redPoints = 0; private redPoints = 0;
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
private isInEndGame = false; private isInEndGame = false;
private timeScaling = 1; private timeScaling = 1;
@ -41,7 +43,7 @@ export class GameServer extends CommandReceiver {
private initialize() { private initialize() {
const previousPlayers = this.players; const previousPlayers = this.players;
this.objects = new PhysicalContainer(); this.objects = new PhysicalContainer();
createWorld(this.objects, this.options.worldSize); createWorld(this.objects);
this.objects.initialize(); this.objects.initialize();
this.players = new PlayerContainer( this.players = new PlayerContainer(
this.objects, this.objects,
@ -52,6 +54,7 @@ export class GameServer extends CommandReceiver {
this.deltaTimes = []; this.deltaTimes = [];
this.declaPoints = 0; this.declaPoints = 0;
this.redPoints = 0; this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false; this.isInEndGame = false;
this.timeScaling = 1; this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.queueCommandForEachClient(new GameStartCommand());
@ -126,6 +129,23 @@ export class GameServer extends CommandReceiver {
this.endGame(CharacterTeam.decla); this.endGame(CharacterTeam.decla);
} else if (this.redPoints >= this.options.scoreLimit) { } else if (this.redPoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.red); this.endGame(CharacterTeam.red);
} else {
this.announceMatchPointOnce(CharacterTeam.decla, this.declaPoints);
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
}
}
private announceMatchPointOnce(team: CharacterTeam, points: number) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
} }
} }

View file

@ -12,6 +12,8 @@ import {
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
mix,
clamp01,
} from 'shared'; } from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
@ -35,8 +37,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private static readonly feetRadius = 20; private static readonly feetRadius = 20;
private projectileStrength = settings.playerMaxStrength; private projectileStrength = settings.playerMaxStrength;
// offsets are measured from (0, 0) // offsets are measured from (0, 0). The head sits this far above the feet,
private static readonly desiredHeadOffset = vec2.fromValues(0, 65); // which sets the leg length: kept short so the body reads as a compact head on
// stubby legs rather than a long-legged strider.
private static readonly desiredHeadOffset = vec2.fromValues(0, 55);
private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0);
private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); private static readonly desiredRightFootOffset = vec2.fromValues(20, 0);
private static readonly centerOfMass = vec2.scale( private static readonly centerOfMass = vec2.scale(
@ -76,15 +80,21 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private isDestroyed = false; private isDestroyed = false;
private timeSinceBorn = 0; private timeSinceBorn = 0;
private hasJustBorn = true; private hasJustBorn = true;
private timeAlive = 0;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private direction = 0; private direction = 0;
private currentPlanet?: PlanetPhysical; private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = 1000; private secondsSinceOnSurface = settings.planetDetachmentSeconds;
public head: CirclePhysical; public head: CirclePhysical;
public leftFoot: CirclePhysical; public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical; public rightFoot: CirclePhysical;
public bound: CirclePhysical;
private movementActions: Array<MoveActionCommand> = []; private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
@ -128,12 +138,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.head); container.addObject(this.head);
container.addObject(this.leftFoot); container.addObject(this.leftFoot);
container.addObject(this.rightFoot); container.addObject(this.rightFoot);
}
this.bound = new CirclePhysical( private get isSpawnProtected(): boolean {
vec2.create(), return (
CharacterPhysical.boundRadius, this.timeAlive <
this, settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
container,
); );
} }
@ -156,9 +166,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c); this.movementActions.push(c);
} }
public addKill() { public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
}
public addKill(victimName: string) {
this.killCount++; this.killCount++;
this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
this.remoteCall('onKillConfirmed', victimName, this.killStreak);
}
public registerHit() {
this.remoteCall('onHitConfirmed');
}
private syncHealth() {
const rounded = Math.round(this.health);
if (rounded !== this.lastSyncedHealth) {
this.lastSyncedHealth = rounded;
this.remoteCall('setHealth', this.health);
}
} }
public onCollision({ other }: ReactToCollisionCommand) { public onCollision({ other }: ReactToCollisionCommand) {
@ -168,28 +202,54 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
other.isAlive other.isAlive
) { ) {
other.destroy(); other.destroy();
this.health -= other.strength;
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
this.onDie();
other.originator.addKill();
}
}
}
public shootTowards(position: vec2) { if (this.isSpawnProtected) {
if (!this.isAlive) {
return; return;
} }
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
this.onDie();
other.originator.addKill(this.name);
} else {
other.originator.registerHit();
}
}
}
public shootTowards(position: vec2, charge = 0) {
if (
!this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval ||
this.projectileStrength < settings.chargeShotStrengthMin
) {
return;
}
this.timeSinceLastShot = 0;
const c = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
c,
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
const direction = vec2.subtract(vec2.create(), position, this.center); const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction); vec2.normalize(direction, direction);
const velocity = vec2.scale(direction, direction, settings.projectileSpeed); const velocity = vec2.scale(direction, direction, speed);
const strength = this.projectileStrength / 2;
this.projectileStrength -= strength;
const projectile = new ProjectilePhysical( const projectile = new ProjectilePhysical(
vec2.clone(this.center), vec2.clone(this.center),
20, radius,
strength, strength,
this.team, this.team,
velocity, velocity,
@ -202,8 +262,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
public get boundingBox(): BoundingBoxBase { public get boundingBox(): BoundingBoxBase {
this.bound.center = this.head.center; return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
return this.bound.boundingBox;
} }
public get gameObject(): this { public get gameObject(): this {
@ -257,6 +316,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds,
),
]); ]);
} }
@ -298,6 +362,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game); this.getPoints(game);
this.timeAlive += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle( const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center), vec2.clone(this.leftFoot.center),
@ -328,16 +393,23 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return; return;
} }
if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined; this.currentPlanet = undefined;
} }
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min( this.projectileStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
this.projectileStrength + this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.regenerateHealth(deltaTimeInSeconds);
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds);
const intersectingWithForceField = this.container.findIntersecting( const intersectingWithForceField = this.container.findIntersecting(
@ -351,8 +423,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const direction = this.averageAndResetMovementActions(); const direction = this.averageAndResetMovementActions();
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds); this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
if (!this.currentPlanet) { if (!this.currentPlanet) {
const leftFootGravity = forceAtPosition( const leftFootGravity = forceAtPosition(
@ -364,8 +436,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
intersectingWithForceField, intersectingWithForceField,
); );
this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds); this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds);
this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds); this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
@ -377,8 +449,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) { if (
vec2.scale(gravity, gravity, 0.35); vec2.dot(movementForce, gravity) <
-vec2.length(movementForce) * settings.climbDotThreshold
) {
vec2.scale(gravity, gravity, settings.climbGravityScale);
} }
const scaledLeftFootGravity = vec2.scale( const scaledLeftFootGravity = vec2.scale(
@ -386,7 +461,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.leftFoot.lastNormal, this.leftFoot.lastNormal,
vec2.dot(this.leftFoot.lastNormal, gravity), vec2.dot(this.leftFoot.lastNormal, gravity),
); );
this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale( const scaledRightFootGravity = vec2.scale(
vec2.create(), vec2.create(),
@ -394,15 +469,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.dot(this.rightFoot.lastNormal, gravity), vec2.dot(this.rightFoot.lastNormal, gravity),
); );
this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds);
if (vec2.length(gravity) <= 100) { if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
this.currentPlanet = undefined; this.currentPlanet = undefined;
} }
this.setDirection(gravity); this.setDirection(gravity);
} }
this.keepPosture(deltaTimeInSeconds); this.keepPosture();
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
this.stepBodyPart(this.head, deltaTimeInSeconds); this.stepBodyPart(this.head, deltaTimeInSeconds);
@ -418,56 +493,57 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
} }
private keepPosture(deltaTime: number) { private keepPosture() {
const center = this.center; const center = this.center;
this.springMove( this.springMove(
this.leftFoot, this.leftFoot,
center, center,
CharacterPhysical.leftFootOffset, CharacterPhysical.leftFootOffset,
deltaTime, settings.postureFeetStiffness,
3000,
); );
this.springMove( this.springMove(
this.rightFoot, this.rightFoot,
center, center,
CharacterPhysical.rightFootOffset, CharacterPhysical.rightFootOffset,
deltaTime, settings.postureFeetStiffness,
3000,
); );
this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000); this.springMove(
this.head,
center,
CharacterPhysical.headOffset,
settings.postureHeadStiffness,
);
} }
private springMove( private springMove(
object: CirclePhysical, object: CirclePhysical,
center: vec2, center: vec2,
offset: vec2, offset: vec2,
deltaTime: number, stiffness: number,
strength: number,
) { ) {
const desiredPosition = vec2.add(vec2.create(), center, offset); const desiredPosition = vec2.add(vec2.create(), center, offset);
vec2.rotate(desiredPosition, desiredPosition, center, this.direction); vec2.rotate(desiredPosition, desiredPosition, center, this.direction);
const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center);
const positionDeltaLength = vec2.length(positionDelta); // First-order velocity relaxation toward the desired posture position,
// added to (not replacing) the gravity/movement velocity accumulated
if (positionDeltaLength > 0) { // earlier this tick. stepManually applies it over deltaTime, so the
const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta); // per-tick displacement is positionDelta * stiffness * deltaTime.
vec2.scale( vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness);
positionDelta,
positionDeltaDirection,
positionDeltaLength ** 2 * deltaTime * strength,
);
if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) {
vec2.scale(
positionDelta,
positionDelta,
positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime),
);
} }
object.applyForce(positionDelta, deltaTime); private regenerateHealth(deltaTimeInSeconds: number) {
this.timeSinceLastDamage += deltaTimeInSeconds;
if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
this.health < settings.playerMaxHealth
) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
);
this.syncHealth();
} }
} }
@ -479,14 +555,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
} }
public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) {
vec2.add(
circle.velocity,
circle.velocity,
vec2.scale(vec2.create(), force, timeInSeconds),
);
}
public onDie() { public onDie() {
this.isDestroyed = true; this.isDestroyed = true;
this.remoteCall('onDie'); this.remoteCall('onDie');

View file

@ -32,6 +32,10 @@ export class LampPhysical extends LampBase implements StaticPhysical {
return this; return this;
} }
public queueSetLight(color: vec3, lightness: number) {
this.remoteCall('setLight', color, lightness);
}
public distance(target: vec2): number { public distance(target: vec2): number {
return vec2.distance(this.center, target); return vec2.distance(this.center, target);
} }

View file

@ -4,6 +4,7 @@ import {
clamp, clamp,
clamp01, clamp01,
id, id,
mix,
serializesTo, serializesTo,
settings, settings,
PlanetBase, PlanetBase,
@ -18,20 +19,38 @@ import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical'; import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical';
@serializesTo(PlanetBase) @serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical { export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = false; public readonly canMove = false;
public readonly sizePointMultiplier: number;
private _boundingBox?: ImmutableBoundingBox; private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this), [StepCommand.type]: this.step.bind(this),
}; };
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>) { constructor(vertices: Array<vec2>) {
super(id(), vertices); super(id(), vertices);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
} }
public distance(target: vec2): number { public distance(target: vec2): number {
@ -80,14 +99,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private getPoints(game: CommandReceiver) { private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0; this.timeSinceLastPointGeneration = 0;
if (this.team !== CharacterTeam.neutral) {
this.remoteCall('generatedPoints', settings.planetPointGenerationValue);
}
const value = Math.round(
settings.planetPointGenerationValue * this.sizePointMultiplier,
);
game.handleCommand( game.handleCommand(
new GeneratePointsCommand( new GeneratePointsCommand(
this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.decla ? value : 0,
this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.red ? value : 0,
), ),
); );
} }
@ -95,9 +114,40 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.timeSinceLastPointGeneration += deltaTimeInSeconds; this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control. // In reverse order, so that teams can achieve a 100% control.
this.getPoints(game); this.getPoints(game);
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds); this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds);
this.detectFlip(game);
}
private detectFlip(game: CommandReceiver) {
const currentTeam = this.team;
if (currentTeam === this.lastTeam) {
return;
}
this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round(
settings.captureFlipPointReward * this.sizePointMultiplier,
);
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.decla ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
);
}
const control = Math.abs(this.ownership - 0.5) / 0.5;
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam);
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
@ -153,7 +203,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getForce(position: vec2): vec2 { public getForce(position: vec2): vec2 {
const diff = vec2.subtract(vec2.create(), this.center, position); const diff = vec2.subtract(vec2.create(), this.center, position);
const dist = Math.max(0, vec2.length(diff) - this.radius); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
vec2.normalize(diff, diff); vec2.normalize(diff, diff);
const scale = clamp( const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),

View file

@ -53,6 +53,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return !this.isDestroyed; return !this.isDestroyed;
} }
public get direction(): vec2 {
const direction = vec2.clone(this.velocity);
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.fromValues(0, -1);
}
private moveOutsideOfObject() { private moveOutsideOfObject() {
let wasCollision = true; let wasCollision = true;
const delta = vec2.scale( const delta = vec2.scale(

View file

@ -5,5 +5,4 @@ export interface Options {
scoreLimit: number; scoreLimit: number;
npcCount: number; npcCount: number;
seed: number; seed: number;
worldSize: number;
} }

View file

@ -6,6 +6,7 @@ import {
Random, Random,
MoveActionCommand, MoveActionCommand,
CharacterTeam, CharacterTeam,
Id,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
@ -13,16 +14,77 @@ import { PlayerBase } from './player-base';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physicals/physical'; import { Physical } from '../physics/physicals/physical';
const npcTuning = {
planIntervalSeconds: 1,
shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10,
reactionObserveRadius: 1400,
planScanRadius: 3500,
aggressionMin: 0.25,
aggressionMax: 1,
wanderReconsiderSeconds: 5,
wanderProbability: 0.4,
wanderTurn: 0.3,
fleeBaseRange: 240,
fleeAggressionFalloff: 1.4,
chaseBaseRange: 700,
chaseAggressionRange: 1600,
captureHoldEnemyDistance: 500,
dodgeThreatRange: 450,
dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35,
dodgeCooldownSeconds: 0.5,
lineOfSightClearance: 20,
lineOfSightStartOffset: 100,
fireBaseChance: 0.45,
fireAggressionChance: 0.45,
chargeRangeThreshold: 500,
chargeBaseChance: 0.3,
chargeAggressionChance: 0.4,
chargeMin: 0.6,
spreadBase: 60,
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
};
export class NPC extends PlayerBase { export class NPC extends PlayerBase {
private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom()); private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastFindTarget = 10000; private timeSinceLastPlan = 10000;
private timeSinceLastFindShootTarget = 10000; private timeSinceLastShoot = 10000;
private isWandering = false; private isWandering = false;
private timeSinceLastWanderingConsideration = 0; private timeSinceLastWanderingConsideration = 0;
private isComingBack = false; private isComingBack = false;
private readonly aggression = Random.getRandomInRange(
npcTuning.aggressionMin,
npcTuning.aggressionMax,
);
private aimTargetId: Id = null;
private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0;
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
private nearObjects: Array<Physical> = [];
constructor( constructor(
playerInfo: PlayerInformation, playerInfo: PlayerInformation,
playerContainer: PlayerContainer, playerContainer: PlayerContainer,
@ -34,172 +96,305 @@ export class NPC extends PlayerBase {
this.step(0); this.step(0);
} }
private findTarget() {
if (
(!this.isComingBack && vec2.length(this.center) > settings.worldRadius) ||
(this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center);
return;
}
this.isComingBack = false;
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 2000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const characters = this.findNearCharactersSorted(nearObjects);
if (characters.length > 0) {
const nearest = characters[0];
if (nearest.distance < 200) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
const enemies = characters.filter((o) => o.character.team !== this.team);
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance < 500) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance > 1000) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
}
if (this.isWandering) {
vec2.rotate(
this.direction,
this.direction,
vec2.create(),
Random.getRandomInRange(-0.2, 0.2),
);
} else {
const planets = this.findNearPlanetsSorted(nearObjects).filter(
(p) => p.planet.team !== this.team,
);
if (planets.length > 0) {
vec2.subtract(this.direction, planets[0].planet.center, this.center);
} else {
this.isWandering = true;
}
}
}
private findNearCharactersSorted(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const characters = Array.from(
new Set(
nearObjects.filter(
(o) =>
o.gameObject instanceof CharacterPhysical && o.gameObject !== this.character,
),
),
).map((c) => ({
character: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as CharacterPhysical).center),
})) as Array<{ character: CharacterPhysical; distance: number }>;
characters.sort((a, b) => a.distance - b.distance);
return characters;
}
private findNearPlanetsSorted(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter((o) => o.gameObject instanceof PlanetPhysical)
.map((c) => ({
planet: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center),
})) as Array<{ planet: PlanetPhysical; distance: number }>;
planets.sort((a, b) => a.distance - b.distance);
return planets;
}
private findShootTarget(): vec2 | undefined {
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const enemies = nearObjects.filter(
(o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team,
);
if (enemies.length > 0) {
return (enemies[0].gameObject as CharacterPhysical).center;
}
}
private timeUntilRespawn = 0; private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
if (this.character) { if (this.character) {
this.center = this.character?.center; this.center = this.character.center;
if (!this.character.isAlive) { if (!this.character.isAlive) {
this.sumDeaths++; this.sumDeaths++;
this.sumKills = this.character.killCount; this.sumKills = this.character.killCount;
this.character = null; this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout; this.timeUntilRespawn = settings.playerDiedTimeout;
return;
} }
} else { } else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter(); this.createCharacter();
this.center = this.character!.center; this.center = this.character!.center;
} }
return;
} }
if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0; this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() > 0.5; this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
} }
if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
this.timeSinceLastFindTarget = 0; this.timeSinceLastPlan = 0;
this.findTarget(); this.plan();
} }
if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) { if (
if (Random.getRandom() > 0.5) { (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
const shootTarget = this.findShootTarget(); ) {
if (shootTarget) { this.timeSinceObserve = 0;
vec2.add( this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
shootTarget, }
shootTarget,
vec2.fromValues( this.dodgeCommitRemaining -= deltaTimeInSeconds;
Random.getRandomInRange(-200, 200), this.dodgeCooldownRemaining -= deltaTimeInSeconds;
Random.getRandomInRange(-200, 200), const movement = this.decideMovement(this.nearObjects);
), this.character.handleMovementAction(new MoveActionCommand(movement));
);
this.character?.shootTowards(shootTarget); if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {
this.timeSinceLastShoot = 0;
this.tryShoot(this.nearObjects);
} }
} }
this.timeSinceLastFindShootTarget = 0; private plan() {
const distanceFromCentre = vec2.length(this.center);
if (
(!this.isComingBack && distanceFromCentre > settings.worldRadius) ||
(this.isComingBack && distanceFromCentre > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.negate(this.direction, this.center);
return;
}
this.isComingBack = false;
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
} }
this.character?.handleMovementAction(new MoveActionCommand(this.direction)); if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
} }
protected createCharacter() { if (!this.isWandering) {
const randomPoint = vec2.rotate( const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) {
vec2.subtract(this.direction, planet.planet.center, this.center);
return;
}
}
vec2.rotate(
this.direction,
this.direction,
vec2.create(), vec2.create(),
vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
vec2.create(),
Random.getRandomInRange(0, Math.PI * 2),
); );
super.createCharacter(randomPoint); }
private decideMovement(nearObjects: Array<Physical>): vec2 {
if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection);
}
if (this.dodgeCooldownRemaining <= 0) {
const dodge = this.dodgeVector(nearObjects);
if (dodge) {
if (
Random.getRandom() <
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
) {
vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection);
}
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
}
}
const planet = this.character!.groundPlanet;
if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects);
if (
enemies.length === 0 ||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
) {
return vec2.create();
}
}
return vec2.normalize(vec2.create(), this.direction);
}
private dodgeVector(nearObjects: Array<Physical>): vec2 | undefined {
let threat: ProjectilePhysical | undefined;
let threatDistance = Infinity;
for (const o of nearObjects) {
const p = o.gameObject;
if (!(p instanceof ProjectilePhysical) || p.team === this.team || !p.isAlive) {
continue;
}
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe);
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
continue;
}
vec2.normalize(toMe, toMe);
if (
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
distance < threatDistance
) {
threatDistance = distance;
threat = p;
}
}
if (!threat) {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);
}
vec2.normalize(perpendicular, perpendicular);
return perpendicular;
}
private tryShoot(nearObjects: Array<Physical>) {
const enemies = this.enemiesByDistance(nearObjects);
const visible = enemies.find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects),
);
if (!visible) {
this.aimTargetId = null;
return;
}
const target = visible.character;
const distance = visible.distance;
const velocity = vec2.create();
if (this.aimTargetId === target.id) {
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
}
this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center);
if (
Random.getRandom() >
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
) {
return;
}
const charge =
distance > npcTuning.chargeRangeThreshold &&
Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1)
: 0;
const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const leadTime = distance / projectileSpeed;
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
const spread =
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
(npcTuning.spreadAggressionFalloff - this.aggression);
aim.x += Random.getRandomInRange(-spread, spread);
aim.y += Random.getRandomInRange(-spread, spread);
this.character!.shootTowards(aim, charge);
}
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
const planets: Array<PlanetPhysical> = [];
for (const o of nearObjects) {
if (o.gameObject instanceof PlanetPhysical) {
planets.push(o.gameObject);
}
}
if (planets.length === 0) {
return true;
}
const direction = vec2.subtract(vec2.create(), target, this.center);
const totalDistance = vec2.length(direction);
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
return true;
}
vec2.normalize(direction, direction);
let traveled = npcTuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) {
let sdf = Infinity;
for (const planet of planets) {
sdf = Math.min(sdf, planet.distance(position));
}
if (sdf < npcTuning.lineOfSightClearance) {
return false;
}
traveled += sdf;
vec2.scaleAndAdd(position, position, direction, sdf);
}
return true;
}
private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, radius)),
);
}
private enemiesByDistance(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const seen = new Set<CharacterPhysical>();
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
for (const o of nearObjects) {
const c = o.gameObject;
if (
c instanceof CharacterPhysical &&
c !== this.character &&
c.isAlive &&
c.team !== this.team &&
!seen.has(c)
) {
seen.add(c);
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
}
}
enemies.sort((a, b) => a.distance - b.distance);
return enemies;
}
private capturablePlanetsByDistance(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter(
(o): o is Physical =>
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
)
.map((o) => ({
planet: o.gameObject as PlanetPhysical,
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
}));
planets.sort((a, b) => a.distance - b.distance);
return planets;
} }
} }

View file

@ -1,9 +1,17 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } from 'shared'; import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
settings,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
export abstract class PlayerBase extends CommandReceiver { export abstract class PlayerBase extends CommandReceiver {
@ -22,14 +30,14 @@ export abstract class PlayerBase extends CommandReceiver {
super(); super();
} }
protected createCharacter(preferredCenter: vec2) { protected createCharacter() {
this.character = new CharacterPhysical( this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20), this.playerInfo.name.slice(0, 20),
this.sumKills, this.sumKills,
this.sumDeaths, this.sumDeaths,
this.team, this.team,
this.objectContainer, this.objectContainer,
this.findEmptyPositionForPlayer(preferredCenter), this.findEmptyPositionForPlayer(this.findSpawnCenter()),
); );
this.objectContainer.addObject(this.character); this.objectContainer.addObject(this.character);
@ -37,11 +45,34 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void; public abstract step(deltaTimeInSeconds: number): void;
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { private findSpawnCenter(): vec2 {
if (!preferredCenter) { const planets = this.objectContainer
preferredCenter = vec2.create(); .findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
const candidates = friendly.length ? friendly : neutral.length ? neutral : planets;
if (candidates.length === 0) {
return vec2.create();
} }
const isContested = (planet: PlanetPhysical) =>
this.playerContainer.players.some(
(p) =>
p.team !== this.team &&
p.character?.isAlive &&
vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance,
);
const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0; let rotation = 0;
let radius = 0; let radius = 0;
for (; ;) { for (; ;) {

View file

@ -42,9 +42,8 @@ export class Player extends PlayerBase {
(this.aspectRatio = v.aspectRatio), (this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) => [MoveActionCommand.type]: (c: MoveActionCommand) =>
this.character?.handleMovementAction(c), this.character?.handleMovementAction(c),
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { [PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position); this.character?.shootTowards(c.position, c.charge),
},
}; };
constructor( constructor(
@ -60,11 +59,7 @@ export class Player extends PlayerBase {
} }
protected createCharacter() { protected createCharacter() {
const preferredCenter = this.playerContainer.players.find( super.createCharacter();
(p) => p.character?.isAlive && p.team === this.team,
)?.center;
super.createCharacter(preferredCenter ?? vec2.create());
this.objectsPreviouslyInViewArea.push(this.character!); this.objectsPreviouslyInViewArea.push(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!)); this.queueCommandSend(new CreatePlayerCommand(this.character!));
@ -206,8 +201,4 @@ export class Player extends PlayerBase {
this.queueCommandSend(new ServerAnnouncement(announcement)); this.queueCommandSend(new ServerAnnouncement(announcement));
} }
} }
public destroy() {
super.destroy();
}
} }

View file

@ -1,7 +0,0 @@
import { Command, GameObject } from 'shared';
export class ReactToCollisionCommand extends Command {
public constructor(public readonly other: GameObject) {
super();
}
}

View file

@ -23,7 +23,7 @@
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0", "sass": "^1.100.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
"sdf-2d": "^0.7.6", "sdf-2d": "file:../../sdf-2d",
"shared": "file:../shared", "shared": "file:../shared",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2", "socket.io-msgpack-parser": "^3.0.2",
@ -36,6 +36,40 @@
"webpack-dev-server": "^5.2.4" "webpack-dev-server": "^5.2.4"
} }
}, },
"../../sdf-2d": {
"version": "0.7.6",
"dev": true,
"license": "ISC",
"dependencies": {
"gl-matrix": "^3.4.4",
"resize-observer-polyfill": "^1.5.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"raw-loader": "^4.0.2",
"terser-webpack-plugin": "^5.6.1",
"ts-loader": "^9.6.0",
"typedoc": "^0.28.19",
"typedoc-plugin-extras": "^4.0.1",
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3"
}
},
"../../sdf-2d/dist": {
"extraneous": true
},
"../../sdf-2d/lib": {
"extraneous": true
},
"../shared": { "../shared": {
"version": "0.0.0", "version": "0.0.0",
"dev": true, "dev": true,
@ -5409,15 +5443,8 @@
} }
}, },
"node_modules/sdf-2d": { "node_modules/sdf-2d": {
"version": "0.7.6", "resolved": "../../sdf-2d",
"resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.7.6.tgz", "link": true
"integrity": "sha512-aBJUjwYjWP+/fSvLHH6vhpFyWeSJNCSEHpE0XBX69s2bQlo/NOUr3nq/KuhRZ6A3FeXrGU4lzyurHJ1N8f4rFg==",
"dev": true,
"license": "ISC",
"dependencies": {
"gl-matrix": "^3.3.0",
"resize-observer-polyfill": "^1.5.1"
}
}, },
"node_modules/select-hose": { "node_modules/select-hose": {
"version": "2.0.0", "version": "2.0.0",

View file

@ -37,7 +37,7 @@
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0", "sass": "^1.100.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
"sdf-2d": "^0.7.6", "sdf-2d": "^0.8.0",
"shared": "file:../shared", "shared": "file:../shared",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2", "socket.io-msgpack-parser": "^3.0.2",

View file

@ -20,6 +20,7 @@
html { html {
font-size: 0.85rem; font-size: 0.85rem;
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
font-size: 0.6rem; font-size: 0.6rem;
} }
@ -30,17 +31,21 @@ img {
} }
h1 { h1 {
&, &,
* { * {
font-family: 'Comfortaa', sans-serif; font-family: 'Comfortaa', sans-serif;
font-weight: 400; font-weight: 400;
} }
font-size: 6rem; font-size: 6rem;
text-align: center; text-align: center;
padding-bottom: $medium-padding; padding-bottom: $medium-padding;
@media (max-width: $height-breakpoint) { @media (max-width: $height-breakpoint) {
font-size: 4.5rem; font-size: 4.5rem;
} }
@media (max-height: $height-breakpoint) { @media (max-height: $height-breakpoint) {
display: none; display: none;
} }
@ -48,6 +53,7 @@ h1 {
.red { .red {
color: $accent; color: $accent;
&::selection { &::selection {
color: $accent; color: $accent;
background-color: white; background-color: white;
@ -75,8 +81,10 @@ body {
top: 0; top: 0;
@include background; @include background;
#spinner { #spinner {
@include square(20vmax); @include square(20vmax);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square(20vmax); @include square(20vmax);
} }
@ -120,8 +128,10 @@ body {
&.decla { &.decla {
color: $bright-decla; color: $bright-decla;
div:first-child {
.health {
background-color: $bright-decla; background-color: $bright-decla;
&:before { &:before {
background-color: $bright-decla; background-color: $bright-decla;
opacity: 0.3; opacity: 0.3;
@ -131,8 +141,10 @@ body {
&.red { &.red {
color: $bright-red; color: $bright-red;
div:first-child {
.health {
background-color: $bright-red; background-color: $bright-red;
&:before { &:before {
background-color: $bright-red; background-color: $bright-red;
opacity: 0.4; opacity: 0.4;
@ -140,7 +152,7 @@ body {
} }
} }
div:first-child { .health {
position: relative; position: relative;
height: 5px; height: 5px;
border-radius: 1000px; border-radius: 1000px;
@ -155,9 +167,33 @@ body {
} }
} }
div:not(:first-child) { .charge {
letter-spacing: 2px; height: 3px;
margin-top: 2px;
border-radius: 1000px;
background-color: rgba(255, 255, 255, 0.65);
}
.stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8em; font-size: 0.8em;
font-weight: 700;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
.stat {
display: inline-flex;
align-items: center;
gap: 3px;
}
.icon {
width: 1.1em;
height: 1.1em;
fill: currentColor;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
}
} }
} }
@ -172,9 +208,13 @@ body {
.falling-point { .falling-point {
font-size: 1.2em; font-size: 1.2em;
font-weight: 700;
animation: falling-point 2s ease-out forwards;
&.decla { &.decla {
color: $bright-decla; color: $bright-decla;
} }
&.red { &.red {
color: $bright-red; color: $bright-red;
} }
@ -184,6 +224,7 @@ body {
transition: transform 150ms; transition: transform 150ms;
@include square($large-icon); @include square($large-icon);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square($small-icon); @include square($small-icon);
} }
@ -219,6 +260,56 @@ body {
} }
} }
.touch-button {
display: none;
pointer-events: auto;
touch-action: none;
cursor: pointer;
$size: $large-icon * 1.6;
@include square($size);
top: auto;
left: auto;
bottom: $medium-padding;
border-radius: 1000px;
background-color: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33);
border: $border-width solid rgba(255, 255, 255, 0.4);
font-size: 1.6rem;
line-height: $size - 2 * $border-width;
text-align: center;
text-shadow: 0 0 6px rgba(0, 0, 0, 0.8);
@media (hover: none) and (pointer: coarse) {
display: inline-block;
}
&.fire {
right: $medium-padding;
&::after {
content: '\25C9';
}
}
.strength-ring {
position: absolute;
top: -3px;
left: -3px;
width: calc(100% + 6px);
height: calc(100% + 6px);
border-radius: 1000px;
pointer-events: none;
-webkit-mask: radial-gradient(farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 5px));
mask: radial-gradient(farthest-side,
transparent calc(100% - 5px),
#000 calc(100% - 5px));
}
}
.announcement { .announcement {
top: 25%; top: 25%;
transform: translateX(calc(-50% + 50vw)) translateY(-50%); transform: translateX(calc(-50% + 50vw)) translateY(-50%);
@ -242,20 +333,65 @@ body {
} }
} }
.tutorial-hint {
top: auto;
bottom: calc(#{$medium-padding} + #{$large-icon} * 1.6 + #{$medium-padding});
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 1.6rem;
@include background;
z-index: 1000;
padding: $small-padding $medium-padding;
border-radius: 1000px;
opacity: 0.9;
&:empty {
display: none;
}
}
.planet-progress { .planet-progress {
top: $small-padding; top: $small-padding;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 50%; width: 50%;
display: flex; $height: 20px;
$height: 8px;
height: $height; height: $height;
justify-content: space-between;
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
border-radius: 4px;
z-index: 100; z-index: 100;
pointer-events: none;
background-color: rgba(0, 0, 0, 0.35);
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.4);
border-radius: 1000px;
.fill {
position: absolute;
top: 0;
height: $height;
transition: width $animation-time;
}
.fill.decla {
right: 50%;
background: $bright-decla;
color: $bright-decla;
border-radius: 1000px 0 0 1000px;
}
.fill.red {
left: 50%;
background: $bright-red;
color: $bright-red;
border-radius: 0 1000px 1000px 0;
}
.fill.match-point {
z-index: 1;
animation: match-point-pulse 1.2s ease-in-out infinite;
}
&::before { &::before {
content: ''; content: '';
@ -263,38 +399,87 @@ body {
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
background-color: #888; background-color: #fff;
height: 24px; height: $height + 8px;
width: 4px; width: 3px;
border-radius: 1000px; border-radius: 1000px;
z-index: 2;
} }
.score {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
font-size: 0.95rem;
font-weight: 700;
line-height: 1;
color: #fff;
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 6px rgba(0, 0, 0, 0.8);
opacity: 0.8;
transition:
opacity $animation-time,
text-shadow $animation-time,
font-size $animation-time;
}
.score.decla {
right: calc(50% + #{$small-padding});
}
.score.red {
left: calc(50% + #{$small-padding});
}
.score.leading {
opacity: 1;
font-size: 1.2rem;
}
.score.decla.leading {
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 8px $bright-decla;
}
.score.red.leading {
text-shadow:
0 0 3px rgba(0, 0, 0, 0.95),
0 0 8px $bright-red;
}
.you-marker {
position: absolute;
top: 100%;
margin-top: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; bottom: 100%;
transform: translateX(-50%) translateY(50%); transform: translateX(-50%);
border-left: 4px solid transparent;
@include square(24px); border-right: 4px solid transparent;
border-bottom: 5px solid currentColor;
background-image: url('../static/flag.svg');
background-size: contain;
} }
div { &.decla {
height: $height; right: calc(50% + #{$small-padding});
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); color: $bright-decla;
} }
div:nth-child(1) { &.red {
background: $bright-decla; left: calc(50% + #{$small-padding});
border-radius: 100px 0 0 100px; color: $bright-red;
} }
div:nth-child(2) {
background: $bright-red;
border-radius: 0 100px 100px 0;
} }
} }
} }
@ -308,6 +493,7 @@ body {
background-color: transparent; background-color: transparent;
width: 3px; width: 3px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: $accent; background-color: $accent;
border-radius: $border-radius; border-radius: $border-radius;
@ -321,6 +507,10 @@ body {
bottom: 0; bottom: 0;
left: 0; left: 0;
@media (hover: none) and (pointer: coarse) {
display: none;
}
box-sizing: content-box; box-sizing: content-box;
user-select: none; user-select: none;
@ -328,6 +518,7 @@ body {
padding: $medium-padding; padding: $medium-padding;
@include square($large-icon); @include square($large-icon);
@media (max-width: $breakpoint) { @media (max-width: $breakpoint) {
@include square($small-icon); @include square($small-icon);
padding: $small-padding; padding: $small-padding;
@ -338,3 +529,217 @@ body {
} }
} }
} }
.feedback-hud {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
z-index: 2000;
overflow: hidden;
.hitmarker {
position: absolute;
transform: translate(-50%, -50%);
@include square(26px);
opacity: 0.9;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 2px;
background-color: white;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
transform-origin: center;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
animation: hitmarker-pop 250ms ease-out forwards;
}
.kill-popup {
position: absolute;
transform: translate(-50%, -50%);
font-weight: 700;
font-size: 1.4rem;
white-space: nowrap;
color: $bright-neutral;
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
animation: kill-popup-rise 1200ms ease-out forwards;
.heal {
color: #6fcf6f;
}
}
.streak-callout {
position: absolute;
top: 33%;
left: 50%;
transform: translateX(-50%);
font-weight: 700;
font-size: 2.6rem;
color: $accent;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9);
white-space: nowrap;
animation: streak-pop 1400ms ease-out forwards;
}
.killfeed {
position: absolute;
top: calc(#{$small-padding} + 36px);
right: $medium-padding;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.kill-entry {
font-size: 1rem;
padding: 4px 10px;
border-radius: 1000px;
@include background;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
animation: kill-entry-fade 4500ms ease-out forwards;
b {
color: $accent;
}
}
}
}
.charge-ring {
position: fixed;
@include square(56px);
margin: -28px 0 0 -28px;
border-radius: 1000px;
pointer-events: none;
user-select: none;
z-index: 2000;
opacity: 0;
transition: opacity 100ms;
-webkit-mask: radial-gradient(farthest-side,
transparent calc(100% - 6px),
#000 calc(100% - 6px));
mask: radial-gradient(farthest-side,
transparent calc(100% - 6px),
#000 calc(100% - 6px));
&.full {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
}
}
@keyframes match-point-pulse {
0%,
100% {
box-shadow: 0 0 4px 0 currentColor;
}
50% {
box-shadow: 0 0 14px 3px currentColor;
}
}
@keyframes falling-point {
0% {
opacity: 1;
transform: translate(-50%, -50%);
}
100% {
opacity: 0;
transform: translate(-50%, calc(-50% - 90px));
}
}
@keyframes hitmarker-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.6);
}
25% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
@keyframes kill-popup-rise {
0% {
opacity: 0;
transform: translate(-50%, -30%) scale(0.8);
}
20% {
opacity: 1;
transform: translate(-50%, -60%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -160%) scale(1);
}
}
@keyframes streak-pop {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.6);
}
20% {
opacity: 1;
transform: translateX(-50%) scale(1.1);
}
70% {
opacity: 1;
transform: translateX(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(1);
}
}
@keyframes kill-entry-fade {
0% {
opacity: 0;
transform: translateX(20px);
}
10% {
opacity: 1;
transform: translateX(0);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View file

@ -0,0 +1,58 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
private static raf = 0;
private static followPointer = false;
public static begin(x: number, y: number, followPointer = false) {
this.end();
const element = document.createElement('div');
element.className = 'charge-ring';
element.style.left = `${x}px`;
element.style.top = `${y}px`;
document.body.appendChild(element);
this.element = element;
this.heldSince = performance.now();
this.followPointer = followPointer;
this.raf = requestAnimationFrame(this.update);
}
public static end() {
if (this.element) {
cancelAnimationFrame(this.raf);
this.element.parentElement?.removeChild(this.element);
this.element = undefined;
}
}
private static update = () => {
const element = ChargeIndicator.element;
if (!element) {
return;
}
const charge = holdDurationToCharge(
(performance.now() - ChargeIndicator.heldSince) / 1000,
);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (ChargeIndicator.followPointer) {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
}
}
ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update);
};
}

View file

@ -1,8 +1,14 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } from 'shared'; import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { Pointer } from '../helper/pointer';
export class MouseListener extends CommandGenerator { export class MouseListener extends CommandGenerator {
// Timestamp (ms) of the primary press, or null when not held. On release the
// held duration is mapped to the charge scalar; a quick tap reads as ~0.
private primaryDownAt: number | null = null;
constructor( constructor(
private target: HTMLElement, private target: HTMLElement,
private readonly game: Game, private readonly game: Game,
@ -10,22 +16,43 @@ export class MouseListener extends CommandGenerator {
super(); super();
target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mouseup', this.mouseUpListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener); target.addEventListener('contextmenu', this.contextMenuListener);
} }
private mouseDownListener = (event: MouseEvent) => { // Only the screen position is stored; it is reprojected to world space each
const position = this.positionFromEvent(event); // frame so the gaze stays correct even while the camera pans under a still
// cursor.
private mouseMoveListener = (event: MouseEvent) => {
Pointer.setDisplayPosition(event.clientX, event.clientY);
};
private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) { if (event.button === 0) {
this.sendCommandToSubscribers(new PrimaryActionCommand(position)); this.primaryDownAt = performance.now();
// The ring follows the cursor and only fades in once this press has
// clearly become a hold.
ChargeIndicator.begin(event.clientX, event.clientY, true);
} }
}; };
private mouseUpListener = (event: MouseEvent) => {
if (event.button !== 0 || this.primaryDownAt === null) {
return;
}
ChargeIndicator.end();
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.positionFromEvent(event), charge),
);
};
// Suppress the browser context menu on the canvas; right-click has no action.
private contextMenuListener = (event: MouseEvent) => { private contextMenuListener = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const position = this.positionFromEvent(event);
this.sendCommandToSubscribers(new SecondaryActionCommand(position));
}; };
private positionFromEvent(event: MouseEvent): vec2 { private positionFromEvent(event: MouseEvent): vec2 {
@ -35,7 +62,10 @@ export class MouseListener extends CommandGenerator {
} }
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('mousedown', this.mouseDownListener); this.target.removeEventListener('mousedown', this.mouseDownListener);
this.target.removeEventListener('mouseup', this.mouseUpListener);
this.target.removeEventListener('mousemove', this.mouseMoveListener);
this.target.removeEventListener('contextmenu', this.contextMenuListener); this.target.removeEventListener('contextmenu', this.contextMenuListener);
} }
} }

View file

@ -1,6 +1,14 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, MoveActionCommand, last, PrimaryActionCommand } from 'shared'; import {
CommandGenerator,
MoveActionCommand,
last,
PrimaryActionCommand,
holdDurationToCharge,
settings,
} from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
export class TouchListener extends CommandGenerator { export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8; private static readonly deadZone = 8;
@ -10,6 +18,12 @@ export class TouchListener extends CommandGenerator {
private joystickButton: HTMLElement; private joystickButton: HTMLElement;
private isJoystickActive = false; private isJoystickActive = false;
private touchStartPosition!: vec2; private touchStartPosition!: vec2;
private primaryDownAt: number | null = null;
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
private fireDownAt: number | null = null;
constructor( constructor(
private target: HTMLElement, private target: HTMLElement,
@ -23,6 +37,17 @@ export class TouchListener extends CommandGenerator {
this.joystickButton = document.createElement('div'); this.joystickButton = document.createElement('div');
this.joystick.appendChild(this.joystickButton); this.joystick.appendChild(this.joystickButton);
this.fireButton = document.createElement('div');
this.fireButton.className = 'touch-button fire';
this.fireStrengthRing = document.createElement('div');
this.fireStrengthRing.className = 'strength-ring';
this.fireButton.appendChild(this.fireStrengthRing);
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.overlay.appendChild(this.fireButton);
target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchend', this.touchEndListener);
@ -43,6 +68,8 @@ export class TouchListener extends CommandGenerator {
event.touches[0].clientX, event.touches[0].clientX,
event.touches[0].clientY, event.touches[0].clientY,
); );
this.primaryDownAt = performance.now();
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
} }
}; };
@ -60,6 +87,8 @@ export class TouchListener extends CommandGenerator {
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
this.isJoystickActive = true; this.isJoystickActive = true;
this.primaryDownAt = null;
ChargeIndicator.end();
this.overlay.appendChild(this.joystick); this.overlay.appendChild(this.joystick);
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`;
this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
@ -71,7 +100,8 @@ export class TouchListener extends CommandGenerator {
vec2.set(delta, delta.x, -delta.y); vec2.set(delta, delta.x, -delta.y);
if (deltaLength > TouchListener.deadZone) { if (deltaLength > TouchListener.deadZone) {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta))); const direction = vec2.normalize(delta, delta);
this.sendCommandToSubscribers(new MoveActionCommand(direction));
} else { } else {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
} }
@ -81,12 +111,18 @@ export class TouchListener extends CommandGenerator {
event.preventDefault(); event.preventDefault();
if (!this.isJoystickActive) { if (!this.isJoystickActive) {
ChargeIndicator.end();
const charge =
this.primaryDownAt === null
? 0
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
const center = vec2.fromValues( const center = vec2.fromValues(
event.changedTouches[0].clientX, event.changedTouches[0].clientX,
event.changedTouches[0].clientY, event.changedTouches[0].clientY,
); );
this.sendCommandToSubscribers( this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
); );
} else if (event.touches.length === 0) { } else if (event.touches.length === 0) {
this.isJoystickActive = false; this.isJoystickActive = false;
@ -95,9 +131,62 @@ export class TouchListener extends CommandGenerator {
} }
}; };
private swallowTouch = (event: TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect();
ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2);
};
private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event);
ChargeIndicator.end();
if (this.fireDownAt === null) {
return;
}
const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000);
this.fireDownAt = null;
const character = this.game.gameObjects.player;
if (!character) {
return;
}
const aim = vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
character.facingDirection,
settings.touchAimRange,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
};
public update(_deltaTimeInSeconds: number) {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
}
const character = this.game.gameObjects.player;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
}deg, transparent 0deg)`;
}
}
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchstart', this.touchStartListener);
this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchend', this.touchEndListener);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.fireButton.parentElement?.removeChild(this.fireButton);
} }
} }

View file

@ -0,0 +1,89 @@
import { settings } from 'shared';
import { Pointer } from './helper/pointer';
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 };
}
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);
}
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);
}
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;
}
}

View file

@ -30,10 +30,12 @@ import { CommandSocket } from './commands/command-socket';
import { PlayerDecision } from './join-form-handler'; import { PlayerDecision } from './join-form-handler';
import { GameObjectContainer } from './objects/game-object-container'; import { GameObjectContainer } from './objects/game-object-container';
import parser from 'socket.io-msgpack-parser'; import parser from 'socket.io-msgpack-parser';
import { BlobShape } from './shapes/blob-shape'; import { CharacterShape } from './shapes/character-shape';
import { PlanetShape } from './shapes/planet-shape'; import { PlanetShape } from './shapes/planet-shape';
import { RenderCommand } from './commands/types/render'; import { RenderCommand } from './commands/types/render';
import { StepCommand } from './commands/types/step'; import { StepCommand } from './commands/types/step';
import { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard';
export class Game extends CommandReceiver { export class Game extends CommandReceiver {
public gameObjects = new GameObjectContainer(this); public gameObjects = new GameObjectContainer(this);
@ -48,12 +50,11 @@ export class Game extends CommandReceiver {
private mouseListener: MouseListener; private mouseListener: MouseListener;
private touchListener: TouchListener; private touchListener: TouchListener;
private declaPlanetCountElement = document.createElement('div'); private scoreboard = new Scoreboard();
private redPlanetCountElement = document.createElement('div');
private announcementText = document.createElement('h2'); private announcementText = document.createElement('h2');
private progressBar = document.createElement('div');
private arrows: { [id: number]: HTMLElement } = {}; private arrows: { [id: number]: HTMLElement } = {};
private socketReceiver!: CommandSocket; private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
constructor( constructor(
private readonly playerDecision: PlayerDecision, private readonly playerDecision: PlayerDecision,
@ -63,9 +64,6 @@ export class Game extends CommandReceiver {
super(); super();
this.started = new Promise((r) => (this.resolveStarted = r)); this.started = new Promise((r) => (this.resolveStarted = r));
this.announcementText.className = 'announcement'; this.announcementText.className = 'announcement';
this.progressBar.className = 'planet-progress';
this.progressBar.appendChild(this.declaPlanetCountElement);
this.progressBar.appendChild(this.redPlanetCountElement);
this.keyboardListener = new KeyboardListener(); this.keyboardListener = new KeyboardListener();
this.mouseListener = new MouseListener(this.canvas, this); this.mouseListener = new MouseListener(this.canvas, this);
@ -78,12 +76,14 @@ export class Game extends CommandReceiver {
this.socket?.close(); this.socket?.close();
this.gameObjects = new GameObjectContainer(this); this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = ''; this.overlay.innerHTML = '';
this.arrows = {};
this.isEnding = false; this.isEnding = false;
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
this.overlay.appendChild(this.progressBar); this.overlay.appendChild(this.scoreboard.element);
this.announcementText.innerText = ''; this.announcementText.innerText = '';
this.timeScaling = 1; this.timeScaling = 1;
this.overlay.appendChild(this.announcementText); this.overlay.appendChild(this.announcementText);
this.tutorial = new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, { this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000, reconnectionDelayMax: 10000,
@ -114,12 +114,17 @@ export class Game extends CommandReceiver {
}); });
this.socketReceiver = new CommandSocket(this.socket); this.socketReceiver = new CommandSocket(this.socket);
// The tutorial listens to the same input streams as the socket, so its
// stages clear off the player's own commands without any server involvement.
this.keyboardListener.clearSubscribers(); this.keyboardListener.clearSubscribers();
this.keyboardListener.subscribe(this.socketReceiver); this.keyboardListener.subscribe(this.socketReceiver);
this.keyboardListener.subscribe(this.tutorial);
this.mouseListener.clearSubscribers(); this.mouseListener.clearSubscribers();
this.mouseListener.subscribe(this.socketReceiver); this.mouseListener.subscribe(this.socketReceiver);
this.mouseListener.subscribe(this.tutorial);
this.touchListener.clearSubscribers(); this.touchListener.clearSubscribers();
this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.socketReceiver);
this.touchListener.subscribe(this.tutorial);
this.isBetweenGames = false; this.isBetweenGames = false;
@ -189,8 +194,7 @@ export class Game extends CommandReceiver {
clamp(p.x, arrowPadding, width - arrowPadding), clamp(p.x, arrowPadding, width - arrowPadding),
clamp(height - p.y, arrowPadding, height - arrowPadding), clamp(height - p.y, arrowPadding, height - arrowPadding),
); );
e.style.transform = `translateX(${p.x}px) translateY(${ e.style.transform = `translateX(${p.x}px) translateY(${p.y
p.y
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
}); });
@ -214,7 +218,7 @@ export class Game extends CommandReceiver {
this.canvas, this.canvas,
[ [
PlanetShape.descriptor, PlanetShape.descriptor,
BlobShape.descriptor, CharacterShape.descriptor,
{ {
...CircleLight.descriptor, ...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16], shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
@ -223,10 +227,11 @@ export class Game extends CommandReceiver {
this.gameLoop.bind(this), this.gameLoop.bind(this),
{ {
shadowTraceCount: 16, shadowTraceCount: 16,
paletteSize: settings.palette.length, paletteSize: settings.paletteDim.length,
colorPalette: settings.palette, colorPalette: settings.paletteDim,
enableHighDpiRendering: true, enableHighDpiRendering: true,
lightCutoffDistance: settings.lightCutoffDistance, lightCutoffDistance: settings.lightCutoffDistance,
lightOverlapReduction: settings.lightOverlapReduction,
textures: { textures: {
noiseTexture: { noiseTexture: {
source: noiseTexture, source: noiseTexture,
@ -276,7 +281,9 @@ export class Game extends CommandReceiver {
this.draw(); this.draw();
} }
if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) { if (
(this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds
) {
this.lastAnnouncementText = ''; this.lastAnnouncementText = '';
} }
@ -292,6 +299,10 @@ export class Game extends CommandReceiver {
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
); );
this.touchListener.update(deltaTime);
this.tutorial.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands(); this.socketReceiver.sendQueuedCommands();
return this.isActive; return this.isActive;
@ -299,10 +310,8 @@ export class Game extends CommandReceiver {
private draw() { private draw() {
if (this.lastGameState) { if (this.lastGameState) {
this.declaPlanetCountElement.style.width = // The local player's team is read off the main character once it exists.
(this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%'; this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
this.redPlanetCountElement.style.width =
(this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%';
} }
if (this.lastOtherPlayerDirections) { if (this.lastOtherPlayerDirections) {

View file

@ -0,0 +1,13 @@
import { vec2 } from 'gl-matrix';
export class Pointer {
private static displayPosition: vec2 | null = null;
public static setDisplayPosition(x: number, y: number): void {
Pointer.displayPosition = vec2.fromValues(x, y);
}
public static getDisplayPosition(): vec2 | null {
return Pointer.displayPosition;
}
}

View file

@ -102,7 +102,7 @@ export class JoinFormHandler {
private removeServer(server: ServerChooserOption) { private removeServer(server: ServerChooserOption) {
this.servers = this.servers.filter((s) => s !== server); this.servers = this.servers.filter((s) => s !== server);
if (this.servers.length) { if (!this.servers.length) {
this.joinButton.disabled = true; this.joinButton.disabled = true;
} }
} }
@ -143,16 +143,17 @@ class ServerChooserOption {
this.setServerInfoLabelText(); this.setServerInfoLabelText();
this.socket = io(url, { this.socket = io(url, {
reconnection: false, reconnection: true,
reconnectionAttempts: 5,
timeout: 4000, timeout: 4000,
parser, parser,
} as any); } as any);
// `connect_timeout` was removed in socket.io-client v3+; connection this.socket.io.on('reconnect_failed', this.destroy.bind(this));
// timeouts now surface through `connect_error` (reconnection is disabled).
this.socket.on('connect_error', this.destroy.bind(this)); this.socket.on('connect', () =>
this.socket.on('disconnect', this.destroy.bind(this)); this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates); );
this.socket.on( this.socket.on(
TransportEvents.ServerInfoUpdate, TransportEvents.ServerInfoUpdate,
([playerCount, gameState]: [number, number]) => { ([playerCount, gameState]: [number, number]) => {

View file

@ -15,6 +15,7 @@ import { StepCommand } from '../commands/types/step';
import { Game } from '../game'; import { Game } from '../game';
import { Camera } from './types/camera'; import { Camera } from './types/camera';
import { CharacterView } from './types/character-view'; import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view';
export class GameObjectContainer extends CommandReceiver { export class GameObjectContainer extends CommandReceiver {
protected objects: Map<Id, GameObject> = new Map(); protected objects: Map<Id, GameObject> = new Map();
@ -57,6 +58,16 @@ export class GameObjectContainer extends CommandReceiver {
super(); super();
} }
public get planets(): Array<PlanetView> {
const planets: Array<PlanetView> = [];
this.objects.forEach((o) => {
if (o instanceof PlanetView) {
planets.push(o);
}
});
return planets;
}
protected defaultCommandExecutor(c: Command) { protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c)); this.objects.forEach((o) => o.handleCommand(c));
this.camera.handleCommand(c); this.camera.handleCommand(c);

View file

@ -1,4 +1,5 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CircleLight, Renderer } from 'sdf-2d';
import { import {
Circle, Circle,
@ -6,6 +7,9 @@ import {
CharacterBase, CharacterBase,
CharacterTeam, CharacterTeam,
settings, settings,
clamp,
clamp01,
mix,
CommandExecutors, CommandExecutors,
UpdatePropertyCommand, UpdatePropertyCommand,
} from 'shared'; } from 'shared';
@ -13,15 +17,36 @@ import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step'; import { StepCommand } from '../../commands/types/step';
import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator'; 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 { SoundHandler, Sounds } from '../../sound-handler';
import { VibrationHandler } from '../../vibration-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 { 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 nameElement: HTMLElement = document.createElement('div');
private statsElement: 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 healthElement: HTMLElement = document.createElement('div');
private chargeElement: HTMLElement = document.createElement('div');
public isMainCharacter = false; public isMainCharacter = false;
@ -48,7 +73,12 @@ export class CharacterView extends CharacterBase {
rightFoot?: Circle, rightFoot?: Circle,
) { ) {
super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); 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.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!);
this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!);
@ -56,7 +86,24 @@ export class CharacterView extends CharacterBase {
this.nameElement.className = 'player-tag ' + this.team; this.nameElement.className = 'player-tag ' + this.team;
this.nameElement.innerText = this.name; 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.healthElement);
this.nameElement.appendChild(this.chargeElement);
this.nameElement.appendChild(this.statsElement); this.nameElement.appendChild(this.statsElement);
} }
@ -64,6 +111,29 @@ export class CharacterView extends CharacterBase {
return this.head!.center; 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({ private updateProperty({
propertyKey, propertyKey,
propertyValue, propertyValue,
@ -78,18 +148,25 @@ export class CharacterView extends CharacterBase {
if (propertyKey === 'rightFoot') { if (propertyKey === 'rightFoot') {
this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange); this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange);
} }
if (propertyKey === 'strength') {
this.strengthExtrapolator.addFrame(propertyValue, rateOfChange);
}
} }
public setHealth(health: number) { public setHealth(health: number) {
const previousHealth = this.health; const damage = this.health - health;
super.setHealth(health); super.setHealth(health);
if (damage > 0) {
SoundHandler.play( SoundHandler.play(
Sounds.hit, Sounds.hit,
(0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, Math.min(1, (0.8 * damage) / settings.playerMaxStrength),
); );
this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength);
if (this.isMainCharacter) { if (this.isMainCharacter) {
VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4)); 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 { private step({ deltaTimeInSeconds }: StepCommand): void {
this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds); this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds);
this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds); this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds);
this.rightFoot! = this.rightFootExtrapolator.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) { 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 { private beforeDestroy(): void {
@ -127,15 +248,34 @@ export class CharacterView extends CharacterBase {
this.healthElement.style.width = this.healthElement.style.width =
(50 * this.health) / settings.playerMaxHealth + 'px'; (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!]); this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]);
renderer.addDrawable(this.shape); renderer.addDrawable(this.shape);
if (this.muzzleFlashIntensity > 0) {
renderer.addDrawable(this.muzzleFlash);
}
} }
private getStatsText(): string { private calculateGazeTarget(renderer: Renderer): vec2 {
return `${this.killCount}⚔/${this.deathCount}`; 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 { private calculateTextPosition(): vec2 {

View file

@ -1,18 +1,36 @@
import { vec2, vec3 } from 'gl-matrix'; import { vec2, vec3 } from 'gl-matrix';
import { CircleLight } from 'sdf-2d'; import { CircleLight } from 'sdf-2d';
import { CommandExecutors, Id, LampBase } from 'shared'; import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared';
import { RenderCommand } from '../../commands/types/render'; import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step';
export class LampView extends LampBase { export class LampView extends LampBase {
private light: CircleLight; private light: CircleLight;
private targetColor: vec3;
private targetLightness: number;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this), [RenderCommand.type]: this.draw.bind(this),
[StepCommand.type]: this.step.bind(this),
}; };
constructor(id: Id, center: vec2, color: vec3, lightness: number) { constructor(id: Id, center: vec2, color: vec3, lightness: number) {
super(id, center, color, lightness); super(id, center, color, lightness);
this.light = new CircleLight(center, color, lightness); this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness);
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
public setLight(color: vec3, lightness: number) {
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
private step({ deltaTimeInSeconds }: StepCommand): void {
const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds);
this.light.color = mixRgb(this.light.color, this.targetColor, t);
this.light.intensity += (this.targetLightness - this.light.intensity) * t;
} }
private draw({ renderer }: RenderCommand): void { private draw({ renderer }: RenderCommand): void {

View file

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

View file

@ -48,7 +48,10 @@ export class ProjectileView extends ProjectileBase {
this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds);
this.light.center = this.center; this.light.center = this.center;
this.light.intensity = (0.15 * this.strength) / settings.projectileMaxStrength; this.light.intensity = Math.min(
0.1,
(0.15 * this.strength) / settings.projectileMaxStrength,
);
} }
private draw({ renderer }: RenderCommand): void { private draw({ renderer }: RenderCommand): void {

View file

@ -0,0 +1,71 @@
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) + '%';
const isMatchPoint = (count: number) =>
count / limit >= settings.matchPointScoreRatio;
this.declaFill.classList.toggle('match-point', isMatchPoint(declaCount));
this.redFill.classList.toggle('match-point', isMatchPoint(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';
}
}
}

View file

@ -1,125 +0,0 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
export class BlobShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 headCenters[BLOB_COUNT];
uniform vec2 leftFootCenters[BLOB_COUNT];
uniform vec2 rightFootCenters[BLOB_COUNT];
uniform float headRadii[BLOB_COUNT];
uniform float footRadii[BLOB_COUNT];
uniform int blobColors[BLOB_COUNT];
float blobSmoothMin(float a, float b)
{
const highp float k = 300.0;
highp float res = exp2(-k * a) + exp2(-k * b);
return -log2(res) / k;
}
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
float blobMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < BLOB_COUNT; i++) {
float headDistance = circleDistance(headCenters[i], headRadii[i], target);
float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target);
float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target);
float res = min(
blobSmoothMin(headDistance, leftFootDistance),
blobSmoothMin(headDistance, rightFootDistance)
);
vec2 leftEyeOffset = vec2(-headRadii[i] * 0.35, headRadii[i] * 0.2);
vec2 rightEyeOffset = vec2(headRadii[i] * 0.35, headRadii[i] * 0.2);
float eyeDistance = min(
circleDistance(headCenters[i] + rightEyeOffset, headRadii[i] * 0.25, target),
circleDistance(headCenters[i] + leftEyeOffset, headRadii[i] * 0.25, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + leftEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + rightEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
if (res < minDistance) {
minDistance = res;
color = eyeDistance < 0.0 ? vec4(1.0) : readFromPalette(blobColors[i]);
}
}
return minDistance;
}
`,
distanceFunctionName: 'blobMinDistance',
},
propertyUniformMapping: {
footRadius: 'footRadii',
headRadius: 'headRadii',
rightFootCenter: 'rightFootCenters',
leftFootCenter: 'leftFootCenters',
headCenter: 'headCenters',
color: 'blobColors',
},
uniformCountMacroName: 'BLOB_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new BlobShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
public constructor(private readonly color: number) {
super();
const circle = new Circle(vec2.create(), 200);
this.setCircles([circle, circle, circle]);
}
public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) {
this.head = head;
this.leftFoot = leftFoot;
this.rightFoot = rightFoot;
}
public minDistance(target: vec2): number {
return Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
);
}
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
return {
headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d),
leftFootCenter: vec2.transformMat2d(
vec2.create(),
this.leftFoot.center,
transform2d,
),
rightFootCenter: vec2.transformMat2d(
vec2.create(),
this.rightFoot.center,
transform2d,
),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
};
}
}

View file

@ -0,0 +1,219 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
// A single character silhouette shared by every team (teams differ only by
// colour): a circular head sitting on two thin line-legs that run down to the
// two feet.
export class CharacterShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 characterHeadCenters[CHARACTER_COUNT];
uniform vec2 characterLeftFeet[CHARACTER_COUNT];
uniform vec2 characterRightFeet[CHARACTER_COUNT];
uniform vec2 characterGazeTargets[CHARACTER_COUNT];
uniform float characterHeadRadii[CHARACTER_COUNT];
uniform float characterFootRadii[CHARACTER_COUNT];
uniform int characterColors[CHARACTER_COUNT];
uniform float characterFlash[CHARACTER_COUNT];
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
// Distance to the segment a->b; subtract a radius to get a capsule (a
// rounded thick line).
float segmentDistance(vec2 a, vec2 b, vec2 target) {
vec2 pa = target - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
float characterMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < CHARACTER_COUNT; i++) {
vec2 head = characterHeadCenters[i];
vec2 leftFoot = characterLeftFeet[i];
vec2 rightFoot = characterRightFeet[i];
float headRadius = characterHeadRadii[i];
float footRadius = characterFootRadii[i];
// The server rotates the whole posture toward travel, so the head
// leads: forward is the facing direction, perp its 90deg rotation, so
// the face sits on the leading side and reads at any rotation.
vec2 footAverage = (leftFoot + rightFoot) * 0.5;
vec2 toHead = head - footAverage;
float toHeadLength = length(toHead);
vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0);
vec2 perp = vec2(-forward.y, forward.x);
// A circular head, drawn a bit smaller than the head->feet gap so the
// legs below it read as two distinct LINES rather than being swallowed
// by the head.
float renderRadius = headRadius * 0.75;
float headDistance = circleDistance(head, renderRadius, target);
// Legs as thin line-segments (capsules) from the head down to each
// foot, hard-min'd onto the head so the head stays a crisp circle.
float legThickness = footRadius * 0.35;
float leftLeg = segmentDistance(head, leftFoot, target) - legThickness;
float rightLeg = segmentDistance(head, rightFoot, target) - legThickness;
// Rounded feet at the ends of the lines, kept large enough to stay
// visible even when the leg is short and the head nearly reaches them.
float footRender = footRadius * 0.7;
float leftFootDistance = circleDistance(leftFoot, footRender, target);
float rightFootDistance = circleDistance(rightFoot, footRender, target);
float body = min(
headDistance,
min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance))
);
// Real eyes painted on the leading face — a bright white sclera with a
// dark pupil — rather than carved holes, so the character reads as
// alive instead of hollow-socketed.
// Big white sclera, small pupil: the eye must read as mostly white so
// it looks like an eye, not a dark socket. (An 8-bit albedo caps the
// sclera at pure white, so its area — not its colour — is what makes
// the eye read brighter against the body.)
vec2 eyeBase = head + forward * (renderRadius * 0.26);
vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38);
vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38);
float scleraRadius = renderRadius * 0.26;
float pupilRadius = renderRadius * 0.11;
// The pupil slides toward the gaze target (the cursor for the local
// player, otherwise the travel direction), kept well inside the rim so
// a generous ring of white always frames it. The normalize is guarded
// so a cursor resting exactly on an eye can't produce NaNs.
vec2 gazeTarget = characterGazeTargets[i];
float pupilReach = (scleraRadius - pupilRadius) * 0.6;
vec2 toLeftGaze = gazeTarget - leftEyeCenter;
vec2 toRightGaze = gazeTarget - rightEyeCenter;
vec2 leftGaze = length(toLeftGaze) > 0.001 ? normalize(toLeftGaze) : forward;
vec2 rightGaze = length(toRightGaze) > 0.001 ? normalize(toRightGaze) : forward;
float sclera = min(
circleDistance(leftEyeCenter, scleraRadius, target),
circleDistance(rightEyeCenter, scleraRadius, target)
);
float pupil = min(
circleDistance(leftEyeCenter + leftGaze * pupilReach, pupilRadius, target),
circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target)
);
// Soften each eye edge by at least one screen texel so the colour
// boundary antialiases instead of staircasing when the character is
// small on screen. The fixed world-space term sets a floor on the
// softness when zoomed in; fwidth widens the band to a texel as the
// head shrinks. Computed here in uniform control flow — NOT inside the
// body branch below — so the screen-space derivatives stay defined.
// fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the
// WebGL1 fallback keeps the plain world-space band.
float eyeAaBase = renderRadius * 0.025;
#ifdef WEBGL2_IS_AVAILABLE
float scleraAa = max(eyeAaBase, fwidth(sclera));
float pupilAa = max(eyeAaBase, fwidth(pupil));
#else
float scleraAa = eyeAaBase;
float pupilAa = eyeAaBase;
#endif
if (body < minDistance) {
minDistance = body;
// Brief white punch when taking a hit.
color = mix(
readFromPalette(characterColors[i]),
vec4(1.0),
clamp(characterFlash[i], 0.0, 1.0)
);
// Paint the eyes over the body colour. The sclera albedo is HDR
// (>> 1): the shading pass multiplies it by the (often dim, reddish)
// scene light before clamping to the screen, so an over-bright white
// reads as a clean white eye everywhere instead of dimming into a
// dark socket. Needs the float colour buffer in sdf-2d; on 8-bit
// fallback it simply clamps back to plain white. The smoothstep
// widths (scleraAa / pupilAa, computed above) antialias the colour
// boundary in a zoom-aware way.
color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera));
color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil));
}
}
return minDistance;
}
`,
distanceFunctionName: 'characterMinDistance',
},
propertyUniformMapping: {
footRadius: 'characterFootRadii',
headRadius: 'characterHeadRadii',
rightFootCenter: 'characterRightFeet',
leftFootCenter: 'characterLeftFeet',
headCenter: 'characterHeadCenters',
gazeTarget: 'characterGazeTargets',
color: 'characterColors',
flash: 'characterFlash',
},
uniformCountMacroName: 'CHARACTER_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new CharacterShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
// 0..1 transient white flash on taking a hit. Set per frame.
public hitFlash = 0;
// World point the eyes look at; the pupils slide toward it. Set per frame
// (the cursor for the local player, the travel direction for everyone else).
public gazeTarget = vec2.create();
public constructor(private readonly color: number) {
super();
const circle = new Circle(vec2.create(), 200);
this.setCircles([circle, circle, circle]);
}
public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) {
this.head = head;
this.leftFoot = leftFoot;
this.rightFoot = rightFoot;
}
public minDistance(target: vec2): number {
return Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
);
}
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
return {
headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d),
leftFootCenter: vec2.transformMat2d(
vec2.create(),
this.leftFoot.center,
transform2d,
),
rightFootCenter: vec2.transformMat2d(
vec2.create(),
this.rightFoot.center,
transform2d,
),
gazeTarget: vec2.transformMat2d(vec2.create(), this.gazeTarget, transform2d),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
flash: this.hitFlash,
};
}
}

View file

@ -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,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
}; };
public randomOffset = 0; public randomOffset = 0;
public rotation = 0;
constructor( constructor(
public vertices: Array<vec2>, public vertices: Array<vec2>,
@ -142,6 +153,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,
}; };
} }

View file

@ -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();
} }

View 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 ?? '';
}
}

View file

@ -137,12 +137,6 @@ module.exports = (env, argv) => {
}, },
resolve: { resolve: {
extensions: ['.ts', '.js', '.json'], extensions: ['.ts', '.js', '.json'],
alias: {
// sdf-2d's package.json `exports` only declares an `import` condition,
// which webpack 5 cannot resolve for the production (require) build.
// Point straight at its entry to bypass package exports resolution.
'sdf-2d$': path.resolve(__dirname, 'node_modules/sdf-2d/lib/main.js'),
},
}, },
}; };
}; };

View file

@ -4,11 +4,14 @@ import { Command } from '../../command';
@serializable @serializable
export class PrimaryActionCommand extends Command { export class PrimaryActionCommand extends Command {
public constructor(public readonly position: vec2) { public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
) {
super(); super();
} }
public toArray(): Array<any> { public toArray(): Array<any> {
return [this.position]; return [this.position, this.charge];
} }
} }

View file

@ -1,14 +0,0 @@
import { vec2 } from 'gl-matrix';
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
@serializable
export class SecondaryActionCommand extends Command {
public constructor(public readonly position: vec2) {
super();
}
public toArray(): Array<any> {
return [this.position];
}
}

View file

@ -0,0 +1,5 @@
import { clamp01 } from './clamp';
import { settings } from '../settings';
export const holdDurationToCharge = (heldSeconds: number): number =>
clamp01(heldSeconds / settings.chargeShotFullHoldSeconds);

View file

@ -16,7 +16,6 @@ export * from './commands/command-generator';
export * from './commands/types/actions/move-action'; export * from './commands/types/actions/move-action';
export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/primary-action';
export * from './commands/types/actions/set-aspect-ratio-action'; export * from './commands/types/actions/set-aspect-ratio-action';
export * from './commands/types/actions/secondary-action';
export * from './helper/array'; export * from './helper/array';
export * from './helper/last'; export * from './helper/last';
export * from './helper/rgb'; export * from './helper/rgb';
@ -24,6 +23,7 @@ export * from './helper/hsl';
export * from './helper/rgb255'; export * from './helper/rgb255';
export * from './helper/mix-rgb'; export * from './helper/mix-rgb';
export * from './helper/clamp'; export * from './helper/clamp';
export * from './helper/charge';
export * from './helper/calculate-view-area'; export * from './helper/calculate-view-area';
export * from './helper/circle'; export * from './helper/circle';
export * from './helper/rectangle'; export * from './helper/rectangle';

View file

@ -28,6 +28,10 @@ export class CharacterBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public onShoot(strength: number) { } public onShoot(strength: number) { }
public onHitConfirmed() { }
public onKillConfirmed(victimName?: string, streak?: number) { }
public setHealth(health: number) { public setHealth(health: number) {
this.health = health; this.health = health;
} }

View file

@ -13,6 +13,11 @@ export class LampBase extends GameObject {
super(id); super(id);
} }
// Overridden by LampView (which lerps toward the new colour/brightness); a
// no-op on the wire base.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public setLight(color: vec3, lightness: number) {}
public toArray(): Array<any> { public toArray(): Array<any> {
const { id, center, color, lightness } = this; const { id, center, color, lightness } = this;
return [id, center, color, lightness]; return [id, center, color, lightness];

View file

@ -4,6 +4,7 @@ import { settings } from '../../settings';
import { serializable } from '../../serialization/serializable'; import { serializable } from '../../serialization/serializable';
import { GameObject } from '../game-object'; import { GameObject } from '../game-object';
import { Id } from '../../communication/id'; import { Id } from '../../communication/id';
import { CharacterTeam } from './character-base';
@serializable @serializable
export class PlanetBase extends GameObject { export class PlanetBase extends GameObject {
@ -25,6 +26,8 @@ export class PlanetBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public generatedPoints(value: number) {} public generatedPoints(value: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onFlipped(team: CharacterTeam) {}
public static createPlanetVertices( public static createPlanetVertices(
center: vec2, center: vec2,

View file

@ -6,39 +6,62 @@ const neutralColor = rgb255(82, 165, 64);
const redColor = rgb255(209, 86, 82); const redColor = rgb255(209, 86, 82);
const q = 2.5; const q = 2.5;
const declaColorDim = rgb255(64 * q, 105 * q, 165 * q); const declaColorDim = rgb255(64 * q, 105 * q, 165 * q);
const redColorDim = rgb255(209 * q, 8 * q, 82 * q); const redColorDim = rgb255(209 * q, 86 * q, 82 * q);
const declaPlanetColor = declaColorDim; const declaPlanetColor = declaColorDim;
const redPlanetColor = redColorDim; const redPlanetColor = redColorDim;
export const settings = { export const settings = {
lightCutoffDistance: 600, lightCutoffDistance: 600,
lightOverlapReduction: 0.85,
radiusSteps: 500, radiusSteps: 500,
spawnDespawnTime: 0.7, spawnDespawnTime: 0.7,
worldRadius: 10000, worldRadius: 4000,
objectsOnCircleLength: 0.002, objectsOnCircleLength: 0.002,
updateMessageInterval: 1 / 25, updateMessageInterval: 1 / 25,
planetEdgeCount: 7, planetEdgeCount: 7,
playerKillPoint: 10, playerKillPoint: 25,
takeControlTimeInSeconds: 4, takeControlTimeInSeconds: 2.5,
loseControlTimeInSeconds: 24, loseControlTimeInSeconds: 16,
planetPointGenerationInterval: 1.5, planetPointGenerationInterval: 1.5,
planetPointGenerationValue: 1, planetPointGenerationValue: 1,
planetSizePointMultiplierMax: 4,
captureFlipPointReward: 12,
maxGravityDistance: 800, maxGravityDistance: 800,
minGravityDistance: 1,
maxGravityQ: 5000, maxGravityQ: 5000,
planetControlThreshold: 0.2, planetControlThreshold: 0.2,
playerMaxHealth: 100, playerMaxHealth: 100,
maxGravityStrength: 50000, maxGravityStrength: 50000,
planetMinReferenceRadius: 150,
planetMaxReferenceRadius: 1200,
maxAcceleration: 120000, maxAcceleration: 120000,
playerMaxStrength: 80, playerMaxStrength: 80,
endGameDeltaScaling: 2, endGameDeltaScaling: 2,
playerDiedTimeout: 5, playerDiedTimeout: 5,
playerStrengthRegenerationPerSeconds: 80, playerStrengthRegenerationPerSeconds: 80,
playerOutOfCombatDelaySeconds: 5,
playerHealthRegenerationPerSeconds: 8,
playerKillHealthReward: 25,
spawnInvulnerabilityExtraSeconds: 0.4,
spawnSafetyDistance: 2000,
touchAimRange: 1000,
postureHeadStiffness: 12,
postureFeetStiffness: 10,
planetDetachmentSeconds: 0.5,
planetDetachmentForceThreshold: 100,
climbDotThreshold: 0.8,
climbGravityScale: 0.35,
projectileMaxStrength: 40, projectileMaxStrength: 40,
projectileSpeed: 2500,
projectileMaxBounceCount: 2, projectileMaxBounceCount: 2,
projectileTimeout: 3,
projectileFadeSpeed: 20, projectileFadeSpeed: 20,
projectileCreationInterval: 0.1, projectileCreationInterval: 0.1,
chargeShotFullHoldSeconds: 0.7,
chargeShotStrengthMin: 40,
chargeShotStrengthMax: 100,
chargeShotRadiusMin: 20,
chargeShotRadiusMax: 32,
chargeShotSpeedMin: 2500,
chargeShotSpeedMax: 3400,
playerColorIndexOffset: 3, playerColorIndexOffset: 3,
backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)],
declaColor, declaColor,
@ -104,4 +127,14 @@ export const settings = {
paletteDim: [declaColorDim, neutralColor, redColorDim], paletteDim: [declaColorDim, neutralColor, redColorDim],
targetPhysicsDeltaTimeInSeconds: 1 / 200, targetPhysicsDeltaTimeInSeconds: 1 / 200,
inViewAreaSize: 1920 * 1080 * 4, inViewAreaSize: 1920 * 1080 * 4,
scoreboardHalfWidthPercent: 50,
scoreboardMinFillPercent: 1.5,
matchPointScoreRatio: 0.9,
lampLerpSeconds: 0.5,
lampMinLightness: 0.4,
lampMaxLightness: 1,
lampFlareIntensity: 1.2,
lampFlareDecaySeconds: 0.6,
maxConcurrentFlipFlares: 3,
announcementVisibleSeconds: 2,
}; };