Improve netcode and simplify #4
96 changed files with 1842 additions and 3095 deletions
even more
commit
8f50ebdb97
|
|
@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix';
|
|||
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
|
||||
import { LampPhysical } from './objects/lamp-physical';
|
||||
import { PlanetPhysical } from './objects/planet-physical';
|
||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { Physical } from './physics/physicals/physical';
|
||||
import { PhysicalContainer } from './physics/physical-container';
|
||||
|
||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||
const objects: Array<Physical> = [];
|
||||
const lights: Array<Physical> = [];
|
||||
const planets: Array<PlanetPhysical> = [];
|
||||
const lamps: Array<LampPhysical> = [];
|
||||
|
||||
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const stepCount = circumference * settings.objectsOnCircleLength;
|
||||
const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength;
|
||||
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
||||
const position = vec2.rotate(
|
||||
vec2.create(),
|
||||
|
|
@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
|||
rad,
|
||||
);
|
||||
|
||||
if (objects.length !== 0 && Random.getRandom() > 0.5) {
|
||||
if (planets.length !== 0 && Random.getRandom() > 0.5) {
|
||||
if (
|
||||
evaluateSdf(position, objects) > 200 &&
|
||||
!lights.find((l) => l.distance(position) < 2500)
|
||||
evaluateSdf(position, planets) > 200 &&
|
||||
!lamps.find((l) => l.distance(position) < 2500)
|
||||
) {
|
||||
lights.push(
|
||||
lamps.push(
|
||||
new LampPhysical(
|
||||
position,
|
||||
hsl(
|
||||
|
|
@ -37,56 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
|||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
evaluateSdf(position, objects) > 1400 &&
|
||||
!lights.find((l) => l.distance(position) < 1700)
|
||||
} else if (
|
||||
evaluateSdf(position, planets) > 1400 &&
|
||||
!lamps.find((l) => l.distance(position) < 1700)
|
||||
) {
|
||||
const planet =
|
||||
objects.length === 0
|
||||
? new PlanetPhysical(
|
||||
const isKeystone = planets.length === 0;
|
||||
const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone
|
||||
? [1600, 2400, 80, 300]
|
||||
: [300, 1600, 20, 100];
|
||||
planets.push(
|
||||
new PlanetPhysical(
|
||||
PlanetBase.createPlanetVertices(
|
||||
position,
|
||||
Random.getRandomInRange(1600, 2400),
|
||||
Random.getRandomInRange(1600, 2400),
|
||||
Random.getRandomInRange(80, 300),
|
||||
Random.getRandomInRange(sizeMin, sizeMax),
|
||||
Random.getRandomInRange(sizeMin, sizeMax),
|
||||
Random.getRandomInRange(roughMin, roughMax),
|
||||
),
|
||||
true,
|
||||
)
|
||||
: new PlanetPhysical(
|
||||
PlanetBase.createPlanetVertices(
|
||||
position,
|
||||
Random.getRandomInRange(300, 1600),
|
||||
Random.getRandomInRange(300, 1600),
|
||||
Random.getRandomInRange(20, 100),
|
||||
isKeystone,
|
||||
objectContainer.game,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`);
|
||||
|
||||
objects.push(planet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.info(`Generated ${objects.length} planets`);
|
||||
console.info(`Generated ${lights.length} light`);
|
||||
|
||||
// Each lamp is associated with its nearest planet via the planet SDF
|
||||
// (negative inside), so it advertises that planet's capture.
|
||||
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
lights
|
||||
.filter((l): l is LampPhysical => l instanceof LampPhysical)
|
||||
.forEach((lamp) => {
|
||||
for (const lamp of lamps) {
|
||||
let nearest: PlanetPhysical | undefined;
|
||||
let nearestDistance = Infinity;
|
||||
planets.forEach((planet) => {
|
||||
for (const planet of planets) {
|
||||
const distance = planet.distance(lamp.center);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = planet;
|
||||
}
|
||||
});
|
||||
}
|
||||
nearest?.addLamp(lamp);
|
||||
});
|
||||
}
|
||||
|
||||
[...objects, ...lights].forEach((o) => objectContainer.addObject(o));
|
||||
[...planets, ...lamps].forEach((o) => objectContainer.addObject(o));
|
||||
};
|
||||
|
|
|
|||
4
backend/src/game-events.ts
Normal file
4
backend/src/game-events.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface GameEvents {
|
||||
addPoints(blue: number, red: number): void;
|
||||
announce(text: string): void;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
|
@ -13,39 +12,33 @@ import {
|
|||
GameEndCommand,
|
||||
GameStartCommand,
|
||||
Command,
|
||||
CommandReceiver,
|
||||
CommandExecutors,
|
||||
ServerAnnouncement,
|
||||
JoinRejectionReason,
|
||||
beginPropertyUpdateGeneration,
|
||||
GameObject,
|
||||
PropertyUpdatesForObject,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from './physics/physical-container';
|
||||
import { createWorld } from './create-world';
|
||||
import { Options } from './options';
|
||||
import { GameEvents } from './game-events';
|
||||
import { CarriedScore, PlayerContainer } from './players/player-container';
|
||||
import { ServerFullError } from './players/server-full-error';
|
||||
import { Player } from './players/player';
|
||||
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
||||
|
||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||
const endTitleSeconds = 6;
|
||||
|
||||
// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with
|
||||
// no argument arrives as undefined. socket.io dispatches listeners inside a
|
||||
// `process.nextTick` with no try/catch of its own, so a dereference of an
|
||||
// unchecked payload here would surface as an uncaught exception and take the
|
||||
// whole match down with the process.
|
||||
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const { name, reconnectToken } = payload as Record<string, unknown>;
|
||||
return {
|
||||
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
|
||||
name: name as string,
|
||||
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export class GameServer extends CommandReceiver {
|
||||
export class GameServer implements GameEvents {
|
||||
private objects!: PhysicalContainer;
|
||||
private players!: PlayerContainer;
|
||||
private lastPhysicsBase = process.hrtime.bigint();
|
||||
|
|
@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver {
|
|||
private isInEndGame = false;
|
||||
private timeScaling = 1;
|
||||
private statReportMs = Date.now();
|
||||
private timeSinceLastServerStateUpdate = 0;
|
||||
private timeSinceLastPointUpdate = 0;
|
||||
private physicsAccumulator = 0;
|
||||
private saturatedFrames = 0;
|
||||
private droppedInboundMessages = 0;
|
||||
|
||||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
this.releaseJoinedSockets();
|
||||
this.objects = new PhysicalContainer();
|
||||
createWorld(this.objects);
|
||||
this.objects.initialize();
|
||||
this.players = new PlayerContainer(
|
||||
this.objects,
|
||||
this.options.playerLimit,
|
||||
this.options.npcCount,
|
||||
);
|
||||
this.lastPhysicsBase = process.hrtime.bigint();
|
||||
this.bluePoints = 0;
|
||||
this.redPoints = 0;
|
||||
this.matchPointAnnounced = {};
|
||||
this.isInEndGame = false;
|
||||
this.timeScaling = 1;
|
||||
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
|
||||
previousPlayers?.sendQueuedCommands();
|
||||
}
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[GeneratePointsCommand.type]: this.addPoints.bind(this),
|
||||
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
|
||||
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
|
||||
};
|
||||
private readonly joinedSockets = new Map<
|
||||
Socket,
|
||||
{ player: Player; release: () => void }
|
||||
>();
|
||||
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly io: Server,
|
||||
private options: Options,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.initialize();
|
||||
|
||||
// Both listeners are metered: they exist before any join, so the limiter
|
||||
// inside onPlayerToServer cannot see them, and each one costs a reply or an
|
||||
// allocation an unauthenticated client could otherwise ask for at line rate.
|
||||
io.on('connection', (socket: Socket) => {
|
||||
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
|
||||
if (!this.allowInboundMessage(socket)) {
|
||||
|
|
@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver {
|
|||
});
|
||||
|
||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||
if (!this.allowInboundMessage(socket)) {
|
||||
return;
|
||||
}
|
||||
if (this.allowInboundMessage(socket)) {
|
||||
socket.join(gameStateSubscribedRoom);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private readonly joinedSockets = new Map<
|
||||
Socket,
|
||||
{ player: Player; release: () => void }
|
||||
>();
|
||||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
this.joinedSockets.forEach(({ release }) => release());
|
||||
this.joinedSockets.clear();
|
||||
this.inboundBudget = new WeakMap();
|
||||
|
||||
this.objects = new PhysicalContainer(this);
|
||||
createWorld(this.objects);
|
||||
this.players = new PlayerContainer(
|
||||
this.objects,
|
||||
this.options.playerLimit,
|
||||
this.options.npcCount,
|
||||
);
|
||||
this.lastPhysicsBase = process.hrtime.bigint();
|
||||
this.bluePoints = 0;
|
||||
this.redPoints = 0;
|
||||
this.matchPointAnnounced = {};
|
||||
this.isInEndGame = false;
|
||||
this.timeScaling = 1;
|
||||
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
|
||||
previousPlayers?.sendQueuedCommands();
|
||||
}
|
||||
|
||||
private handleJoin(socket: Socket, payload: unknown) {
|
||||
const playerInfo = asPlayerInformation(payload);
|
||||
|
|
@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver {
|
|||
return;
|
||||
}
|
||||
|
||||
// A reconnect arrives seconds after the drop, long before engine.io times
|
||||
// the dead socket out. Retire that ghost first so its slot, team and score
|
||||
// go back to the returning client.
|
||||
const carried = this.retireGhost(playerInfo.reconnectToken);
|
||||
|
||||
if (this.players.isFull) {
|
||||
|
|
@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver {
|
|||
return;
|
||||
}
|
||||
|
||||
let player: Player;
|
||||
try {
|
||||
player = this.players.createPlayer(playerInfo, socket, carried);
|
||||
} catch (e) {
|
||||
console.error('Failed to register joining player', e);
|
||||
socket.emit(
|
||||
TransportEvents.JoinRejected,
|
||||
e instanceof ServerFullError
|
||||
? JoinRejectionReason.ServerFull
|
||||
: JoinRejectionReason.InvalidRequest,
|
||||
);
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
const player = this.players.createPlayer(playerInfo, socket, carried);
|
||||
|
||||
const onPlayerToServer = (json: string) => {
|
||||
try {
|
||||
|
|
@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver {
|
|||
this.sendServerStateUpdate();
|
||||
}
|
||||
|
||||
// A WeakMap, not a Map: sockets that never join are metered too, and they
|
||||
// have no join-scoped teardown to remove their entry.
|
||||
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
|
||||
private droppedInboundMessages = 0;
|
||||
private allowInboundMessage(socket: Socket): boolean {
|
||||
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP
|
||||
// correction, a VM resync) would make the refill negative and lock the
|
||||
// client out of sending input for as long as the step was.
|
||||
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
|
||||
const nowMs = performance.now();
|
||||
const burst = settings.maxInboundMessageBurst;
|
||||
const perSecond = settings.maxInboundMessagesPerSecond;
|
||||
|
|
@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver {
|
|||
}
|
||||
for (const [socket, { player }] of this.joinedSockets) {
|
||||
if (player.reconnectToken === token) {
|
||||
const carried = { team: player.team, ...player.scoreSnapshot };
|
||||
const carried = { team: player.team, ...player.score };
|
||||
this.removeJoined(socket);
|
||||
return carried;
|
||||
}
|
||||
|
|
@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver {
|
|||
}
|
||||
this.joinedSockets.delete(socket);
|
||||
this.inboundBudget.delete(socket);
|
||||
// Only the listeners this join added: socket.io keeps its own internal
|
||||
// 'error' guard and the connection-scoped PlayerJoining handler here.
|
||||
joined.release();
|
||||
joined.player.destroy();
|
||||
this.players.deletePlayer(joined.player);
|
||||
}
|
||||
|
||||
private releaseJoinedSockets() {
|
||||
this.joinedSockets.forEach(({ release }) => release());
|
||||
this.joinedSockets.clear();
|
||||
this.inboundBudget = new WeakMap();
|
||||
}
|
||||
|
||||
private timeSinceLastServerStateUpdate = 0;
|
||||
public sendServerStateUpdate() {
|
||||
this.io
|
||||
.to(gameStateSubscribedRoom)
|
||||
.emit(TransportEvents.ServerInfoUpdate, [
|
||||
this.players.count,
|
||||
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
|
||||
this.serverInfo.gameStatePercent,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +238,7 @@ export class GameServer extends CommandReceiver {
|
|||
this.handlePhysics();
|
||||
}
|
||||
|
||||
private addPoints({ blue, red }: GeneratePointsCommand) {
|
||||
public addPoints(blue: number, red: number) {
|
||||
if (this.isInEndGame) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -297,39 +255,28 @@ export class GameServer extends CommandReceiver {
|
|||
}
|
||||
}
|
||||
|
||||
public announce(text: string) {
|
||||
this.players.queueCommandForEachClient(new ServerAnnouncement(text));
|
||||
}
|
||||
|
||||
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>!`,
|
||||
),
|
||||
);
|
||||
this.announce(`Match point — team <span class="${team}">${team}</span>!`);
|
||||
}
|
||||
}
|
||||
|
||||
private endGame(winningTeam: CharacterTeam) {
|
||||
this.isInEndGame = true;
|
||||
const endTitleLength = 6;
|
||||
this.players.endGame(winningTeam);
|
||||
this.players.queueCommandForEachClient(new GameEndCommand());
|
||||
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
|
||||
setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
|
||||
}
|
||||
|
||||
private timeSinceLastPointUpdate = 0;
|
||||
private physicsAccumulator = 0;
|
||||
private saturatedFrames = 0;
|
||||
|
||||
private handlePhysics() {
|
||||
const frameStart = process.hrtime.bigint();
|
||||
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
||||
this.lastPhysicsBase = frameStart;
|
||||
|
||||
if (Date.now() - this.statReportMs > 30000) {
|
||||
this.statReportMs = Date.now();
|
||||
private reportStats() {
|
||||
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
||||
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
|
||||
|
||||
|
|
@ -352,6 +299,16 @@ export class GameServer extends CommandReceiver {
|
|||
}
|
||||
}
|
||||
|
||||
private handlePhysics() {
|
||||
const frameStart = process.hrtime.bigint();
|
||||
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
||||
this.lastPhysicsBase = frameStart;
|
||||
|
||||
if (Date.now() - this.statReportMs > 30000) {
|
||||
this.statReportMs = Date.now();
|
||||
this.reportStats();
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||
this.timeSinceLastServerStateUpdate = 0;
|
||||
this.sendServerStateUpdate();
|
||||
|
|
@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver {
|
|||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
||||
scaledDelta /= this.timeScaling;
|
||||
}
|
||||
this.objects.handleCommand(new StepCommand(scaledDelta, this));
|
||||
this.objects.step(scaledDelta);
|
||||
this.players.step(scaledDelta);
|
||||
}
|
||||
|
||||
beginPropertyUpdateGeneration();
|
||||
this.players.stepCommunication(delta);
|
||||
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
|
||||
this.players.stepCommunication(delta, (object) => {
|
||||
if (!propertyUpdates.has(object)) {
|
||||
propertyUpdates.set(object, object.getPropertyUpdates());
|
||||
}
|
||||
return propertyUpdates.get(object);
|
||||
});
|
||||
this.objects.resetRemoteCalls();
|
||||
|
||||
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
|
||||
|
||||
setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
|
||||
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
|
||||
}
|
||||
|
||||
public get serverInfo(): ServerInformation {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io';
|
|||
import express from 'express';
|
||||
import { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared';
|
||||
import { Random, serverInformationEndpoint, settings } from 'shared';
|
||||
import minimist from 'minimist';
|
||||
import { glMatrix } from 'gl-matrix';
|
||||
import { GameServer } from './game-server';
|
||||
|
|
@ -10,7 +10,6 @@ import { defaultOptions } from './options';
|
|||
import parser from 'socket.io-msgpack-parser';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
applyArrayPlugins();
|
||||
|
||||
const optionOverrides = minimist(process.argv.slice(2));
|
||||
const options = {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix';
|
|||
import {
|
||||
id,
|
||||
settings,
|
||||
MoveActionCommand,
|
||||
serializesTo,
|
||||
Circle,
|
||||
CharacterBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
CharacterMovementSnapshot,
|
||||
GameObject,
|
||||
mix,
|
||||
clamp01,
|
||||
strengthToCharge,
|
||||
|
|
@ -20,6 +17,7 @@ import {
|
|||
decayMomentum,
|
||||
tickPlanetDetachment,
|
||||
characterCenter,
|
||||
sumGravity,
|
||||
CharacterWorld,
|
||||
GroundSurface,
|
||||
headRadius,
|
||||
|
|
@ -29,69 +27,45 @@ import {
|
|||
rightFootOffset,
|
||||
boundRadius,
|
||||
} from 'shared';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { Physical } from '../physics/physical';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { ProjectilePhysical } from './projectile-physical';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { PlanetPhysical } from './planet-physical';
|
||||
import {
|
||||
StepCommand,
|
||||
ReactToCollisionCommand,
|
||||
GeneratePointsCommand,
|
||||
} from '../commands/commands';
|
||||
import { PlanetPhysical, planetsIn } from './planet-physical';
|
||||
|
||||
interface BodyPose {
|
||||
head: Circle;
|
||||
leftFoot: Circle;
|
||||
rightFoot: Circle;
|
||||
}
|
||||
const placeholder = new Circle(vec2.create(), 0);
|
||||
|
||||
@serializesTo(CharacterBase)
|
||||
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
|
||||
export class CharacterPhysical extends CharacterBase implements Physical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = true;
|
||||
|
||||
private projectileStrength = settings.playerMaxStrength;
|
||||
|
||||
private timeSinceDying = 0;
|
||||
private isDestroyed = false;
|
||||
private timeSinceBorn = 0;
|
||||
private hasJustBorn = true;
|
||||
private timeAlive = 0;
|
||||
|
||||
private timeSinceLastShot = settings.projectileCreationInterval;
|
||||
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
|
||||
private lastSyncedHealth = settings.playerMaxHealth;
|
||||
|
||||
private killStreak = 0;
|
||||
|
||||
// Held in place: the shared simulation reads and writes these directly, so
|
||||
// the character IS the state it is stepped as (client predictor agrees).
|
||||
public direction = 0;
|
||||
public currentPlanet: GroundSurface | undefined;
|
||||
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||
public readonly bodyVelocity = vec2.create();
|
||||
|
||||
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||
|
||||
public head: CirclePhysical;
|
||||
public leftFoot: CirclePhysical;
|
||||
public rightFoot: CirclePhysical;
|
||||
|
||||
private movementActions: Array<MoveActionCommand> = [];
|
||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
||||
public direction = 0;
|
||||
public currentPlanet: GroundSurface | undefined;
|
||||
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||
public readonly bodyVelocity = vec2.create();
|
||||
|
||||
private headVelocity = new Circle(vec2.create(), 0);
|
||||
private leftFootVelocity = new Circle(vec2.create(), 0);
|
||||
private rightFootVelocity = new Circle(vec2.create(), 0);
|
||||
private projectileStrength = settings.playerMaxStrength;
|
||||
private timeAlive = 0;
|
||||
private timeSinceDying = 0;
|
||||
private isDestroyed = false;
|
||||
private hasJustBorn = true;
|
||||
private hasFiredSinceSpawn = false;
|
||||
private timeSinceLastShot = settings.projectileCreationInterval;
|
||||
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
||||
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
|
||||
private lastSyncedHealth = settings.playerMaxHealth;
|
||||
private killStreak = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
private movementDirections: Array<vec2> = [];
|
||||
private lastMovementDirection = vec2.create();
|
||||
|
||||
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
||||
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
||||
private readonly box = new BoundingBox();
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
|
|
@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
private readonly container: PhysicalContainer,
|
||||
startPosition: vec2,
|
||||
) {
|
||||
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
|
||||
this.head = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, headOffset),
|
||||
headRadius,
|
||||
super(
|
||||
id(),
|
||||
name,
|
||||
killCount,
|
||||
deathCount,
|
||||
team,
|
||||
settings.playerMaxHealth,
|
||||
placeholder,
|
||||
placeholder,
|
||||
placeholder,
|
||||
);
|
||||
const part = (offset: vec2, radius: number) =>
|
||||
new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, offset),
|
||||
radius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.leftFoot = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, leftFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
this.rightFoot = new CirclePhysical(
|
||||
vec2.add(vec2.create(), startPosition, rightFootOffset),
|
||||
feetRadius,
|
||||
this,
|
||||
container,
|
||||
);
|
||||
container.addObject(this.head);
|
||||
container.addObject(this.leftFoot);
|
||||
container.addObject(this.rightFoot);
|
||||
this.head = part(headOffset, headRadius);
|
||||
this.leftFoot = part(leftFootOffset, feetRadius);
|
||||
this.rightFoot = part(rightFootOffset, feetRadius);
|
||||
}
|
||||
|
||||
private readonly movementWorld: CharacterWorld = {
|
||||
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
|
||||
groundsNear: (center, radius) =>
|
||||
this.container
|
||||
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
|
||||
planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
|
||||
stepBody: (body, deltaTimeInSeconds) => {
|
||||
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
||||
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
|
||||
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
||||
return hit instanceof PlanetPhysical ? hit : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
private hasFiredSinceSpawn = false;
|
||||
private get parts(): Array<CirclePhysical> {
|
||||
return [this.head, this.leftFoot, this.rightFoot];
|
||||
}
|
||||
|
||||
private get isSpawnProtected(): boolean {
|
||||
return (
|
||||
!this.hasFiredSinceSpawn &&
|
||||
|
|
@ -146,38 +119,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
);
|
||||
}
|
||||
|
||||
private hasGeneratedPoints = false;
|
||||
private wasKilledInCombat = false;
|
||||
private getPoints(game: CommandReceiver) {
|
||||
if (!this.isAlive && !this.hasGeneratedPoints) {
|
||||
this.hasGeneratedPoints = true;
|
||||
|
||||
if (!this.wasKilledInCombat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
|
||||
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
|
||||
|
||||
game.handleCommand(new GeneratePointsCommand(blue, red));
|
||||
}
|
||||
}
|
||||
|
||||
public get isAlive(): boolean {
|
||||
return !this.isDestroyed;
|
||||
}
|
||||
|
||||
public handleMovementAction(c: MoveActionCommand) {
|
||||
this.movementActions.push(c);
|
||||
}
|
||||
|
||||
// The world only ever hands back planets, so narrowing GroundSurface is safe.
|
||||
public get groundPlanet(): PlanetPhysical | undefined {
|
||||
return this.currentPlanet as PlanetPhysical | undefined;
|
||||
}
|
||||
|
||||
// Continuous movement state streamed to the owning client so its predictor
|
||||
// resumes this simulation instead of guessing. See CharacterMovementSnapshot.
|
||||
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||
return new CharacterMovementSnapshot(
|
||||
this.direction,
|
||||
|
|
@ -189,6 +138,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
);
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBox {
|
||||
const center = this.center;
|
||||
this.box.xMin = center[0] - boundRadius;
|
||||
this.box.xMax = center[0] + boundRadius;
|
||||
this.box.yMin = center[1] - boundRadius;
|
||||
this.box.yMax = center[1] + boundRadius;
|
||||
return this.box;
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
public get center(): vec2 {
|
||||
return characterCenter(this.head, this.leftFoot, this.rightFoot);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return (
|
||||
Math.min(
|
||||
this.head.distance(target),
|
||||
this.leftFoot.distance(target),
|
||||
this.rightFoot.distance(target),
|
||||
) - 5
|
||||
);
|
||||
}
|
||||
|
||||
public setMoveDirection(direction: vec2) {
|
||||
this.movementDirections.push(direction);
|
||||
}
|
||||
|
||||
public addKill(victimName: string, charge = 0) {
|
||||
this.killCount++;
|
||||
this.killStreak++;
|
||||
|
|
@ -214,15 +194,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
}
|
||||
|
||||
public onCollision({ other }: ReactToCollisionCommand) {
|
||||
public onCollision(other: GameObject) {
|
||||
if (
|
||||
// A corpse keeps its collidable circles for the despawn animation; the
|
||||
// isAlive guard stops a flying corpse from eating shots aimed past it.
|
||||
this.isAlive &&
|
||||
other instanceof ProjectilePhysical &&
|
||||
other.team !== this.team &&
|
||||
other.isAlive
|
||||
!this.isAlive ||
|
||||
!(other instanceof ProjectilePhysical) ||
|
||||
other.team === this.team ||
|
||||
!other.isAlive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
other.destroy();
|
||||
|
||||
if (this.isSpawnProtected) {
|
||||
|
|
@ -234,7 +215,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.lastSyncedHealth = Math.round(this.health);
|
||||
this.remoteCall('setHealth', this.health);
|
||||
|
||||
if (this.health <= 0 && this.isAlive) {
|
||||
if (this.health <= 0) {
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
|
|
@ -247,7 +228,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
other.originator.registerHit(other.charge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public shootTowards(position: vec2, charge = 0) {
|
||||
if (
|
||||
|
|
@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.timeSinceLastShot = 0;
|
||||
this.hasFiredSinceSpawn = true;
|
||||
|
||||
const requestedCharge = clamp01(charge);
|
||||
const desiredStrength = mix(
|
||||
settings.chargeShotStrengthMin,
|
||||
settings.chargeShotStrengthMax,
|
||||
requestedCharge,
|
||||
clamp01(charge),
|
||||
);
|
||||
const strength = Math.min(desiredStrength, this.projectileStrength);
|
||||
this.projectileStrength -= strength;
|
||||
|
||||
const c = strengthToCharge(strength);
|
||||
|
||||
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
||||
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
||||
|
||||
vec2.normalize(direction, direction);
|
||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
||||
const shotDirection = vec2.clone(direction);
|
||||
const velocity = vec2.scale(direction, direction, speed);
|
||||
const projectile = new ProjectilePhysical(
|
||||
this.container.addObject(
|
||||
new ProjectilePhysical(
|
||||
vec2.clone(this.center),
|
||||
radius,
|
||||
strength,
|
||||
this.team,
|
||||
velocity,
|
||||
vec2.scale(vec2.create(), direction, speed),
|
||||
this,
|
||||
this.container,
|
||||
c,
|
||||
),
|
||||
);
|
||||
this.container.addObject(projectile);
|
||||
|
||||
if (c > 0) {
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
shotDirection,
|
||||
direction,
|
||||
-settings.chargeShotRecoilMax * c,
|
||||
);
|
||||
}
|
||||
|
|
@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
this.timeSinceLastLeap = 0;
|
||||
this.projectileStrength -= settings.leapStrengthCost;
|
||||
|
||||
// Same impulse the client predicts with (shared), so a leap launches
|
||||
// identically on both sides.
|
||||
applyLeapImpulse(this, this.lastMovementAction.direction);
|
||||
applyLeapImpulse(this, this.lastMovementDirection);
|
||||
this.remoteCall('onLeap');
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
return getBoundingBoxOfCircle(new Circle(this.center, boundRadius));
|
||||
public onDie(killedInCombat = false) {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
this.isDestroyed = true;
|
||||
this.remoteCall('onDie');
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
public get center(): vec2 {
|
||||
return characterCenter(this.head, this.leftFoot, this.rightFoot);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return (
|
||||
Math.min(
|
||||
this.head.distance(target),
|
||||
this.leftFoot.distance(target),
|
||||
this.rightFoot.distance(target),
|
||||
) - 5
|
||||
if (killedInCombat) {
|
||||
const points = settings.playerKillPoint;
|
||||
this.container.game.addPoints(
|
||||
this.team === CharacterTeam.blue ? 0 : points,
|
||||
this.team === CharacterTeam.red ? 0 : points,
|
||||
);
|
||||
}
|
||||
|
||||
private averageAndResetMovementActions(): vec2 {
|
||||
let direction: vec2;
|
||||
if (this.movementActions.length === 0) {
|
||||
direction = vec2.clone(this.lastMovementAction.direction);
|
||||
} else {
|
||||
direction = this.movementActions.reduce(
|
||||
(sum, current) => vec2.add(sum, sum, current.direction),
|
||||
vec2.create(),
|
||||
);
|
||||
|
||||
vec2.scale(direction, direction, 1 / this.movementActions.length);
|
||||
|
||||
const actions = this.movementActions;
|
||||
this.lastMovementAction = actions[actions.length - 1]!;
|
||||
this.movementActions = [];
|
||||
}
|
||||
|
||||
return vec2.length(direction) > 0
|
||||
? vec2.normalize(direction, direction)
|
||||
: vec2.create();
|
||||
}
|
||||
|
||||
private animateScaling(q: number) {
|
||||
this.head.radius = headRadius * q;
|
||||
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||
}
|
||||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('head', this.head, this.headVelocity),
|
||||
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
|
||||
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
|
||||
new UpdatePropertyCommand('head', this.head, headVelocity),
|
||||
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
|
||||
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
|
||||
new UpdatePropertyCommand(
|
||||
'strength',
|
||||
this.projectileStrength,
|
||||
|
|
@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
]);
|
||||
}
|
||||
|
||||
// `previous` is consumed as scratch so this allocates nothing beyond the
|
||||
// Circle it returns.
|
||||
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
|
||||
return new Circle(
|
||||
vec2.scale(
|
||||
previous.center,
|
||||
vec2.subtract(previous.center, current.center, previous.center),
|
||||
1 / deltaTime,
|
||||
),
|
||||
(current.radius - previous.radius) / deltaTime,
|
||||
);
|
||||
}
|
||||
|
||||
private setPropertyUpdates(previous: BodyPose, deltaTime: number) {
|
||||
const rate = CharacterPhysical.rateOfChange;
|
||||
this.headVelocity = rate(previous.head, this.head, deltaTime);
|
||||
this.leftFootVelocity = rate(previous.leftFoot, this.leftFoot, deltaTime);
|
||||
this.rightFootVelocity = rate(previous.rightFoot, this.rightFoot, deltaTime);
|
||||
}
|
||||
|
||||
private get pose(): BodyPose {
|
||||
const snapshot = (circle: Circle) =>
|
||||
new Circle(vec2.clone(circle.center), circle.radius);
|
||||
return {
|
||||
head: snapshot(this.head),
|
||||
leftFoot: snapshot(this.leftFoot),
|
||||
rightFoot: snapshot(this.rightFoot),
|
||||
};
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
this.getPoints(game);
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.timeAlive += deltaTimeInSeconds;
|
||||
this.timeSinceLastLeap += deltaTimeInSeconds;
|
||||
const previousPose = this.pose;
|
||||
this.parts.forEach((part, i) => {
|
||||
vec2.copy(this.previousPose[i].center, part.center);
|
||||
this.previousPose[i].radius = part.radius;
|
||||
});
|
||||
|
||||
if (this.isDestroyed) {
|
||||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
this.destroy();
|
||||
this.container.removeObject(this);
|
||||
} else {
|
||||
this.freeFallCorpse(deltaTimeInSeconds);
|
||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||
}
|
||||
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasJustBorn) {
|
||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
} else if (this.hasJustBorn) {
|
||||
if (this.timeAlive > settings.spawnDespawnTime) {
|
||||
this.hasJustBorn = false;
|
||||
this.animateScaling(1);
|
||||
} else {
|
||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
||||
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
|
||||
}
|
||||
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
|
||||
return;
|
||||
} else {
|
||||
this.stepAlive(deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
this.parts.forEach((part, i) => {
|
||||
const velocity = this.velocities[i];
|
||||
const previous = this.previousPose[i];
|
||||
vec2.subtract(velocity.center, part.center, previous.center);
|
||||
vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds);
|
||||
velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
private stepAlive(deltaTimeInSeconds: number) {
|
||||
tickPlanetDetachment(this, deltaTimeInSeconds);
|
||||
|
||||
this.timeSinceLastShot += deltaTimeInSeconds;
|
||||
|
||||
this.projectileStrength = Math.min(
|
||||
settings.playerMaxStrength,
|
||||
this.projectileStrength +
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
this.regenerateHealth(deltaTimeInSeconds);
|
||||
|
||||
this.groundPlanet?.registerPresence(this);
|
||||
|
||||
// stepCharacterMovement is the shared simulation the client predicts with,
|
||||
// so the two can never drift.
|
||||
const direction = this.averageAndResetMovementActions();
|
||||
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
|
||||
|
||||
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
private freeFallCorpse(deltaTime: number) {
|
||||
const intersecting = this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(
|
||||
new Circle(this.center, boundRadius + settings.maxGravityDistance),
|
||||
),
|
||||
);
|
||||
let grounded = false;
|
||||
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
|
||||
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
|
||||
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
|
||||
const { hitObject } = part.stepManually(deltaTime);
|
||||
if (hitObject instanceof PlanetPhysical) {
|
||||
grounded = true;
|
||||
}
|
||||
}
|
||||
// Brake with the shared model the living body uses, so a flung corpse
|
||||
// comes to a definite stop instead of sliding forever.
|
||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||
}
|
||||
|
||||
private regenerateHealth(deltaTimeInSeconds: number) {
|
||||
this.timeSinceLastDamage += deltaTimeInSeconds;
|
||||
if (
|
||||
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
||||
|
|
@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
);
|
||||
this.syncHealth();
|
||||
}
|
||||
|
||||
this.groundPlanet?.registerPresence(this);
|
||||
|
||||
stepCharacterMovement(
|
||||
this,
|
||||
this.movementWorld,
|
||||
this.averageAndResetMovementDirections(),
|
||||
deltaTimeInSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
public onDie(killedInCombat = false) {
|
||||
this.wasKilledInCombat = killedInCombat;
|
||||
this.isDestroyed = true;
|
||||
this.remoteCall('onDie');
|
||||
private averageAndResetMovementDirections(): vec2 {
|
||||
const direction = vec2.create();
|
||||
if (this.movementDirections.length === 0) {
|
||||
vec2.copy(direction, this.lastMovementDirection);
|
||||
} else {
|
||||
for (const d of this.movementDirections) {
|
||||
vec2.add(direction, direction, d);
|
||||
}
|
||||
vec2.scale(direction, direction, 1 / this.movementDirections.length);
|
||||
this.lastMovementDirection =
|
||||
this.movementDirections[this.movementDirections.length - 1];
|
||||
this.movementDirections = [];
|
||||
}
|
||||
return vec2.normalize(direction, direction);
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
this.container.removeObject(this);
|
||||
this.container.removeObject(this.head);
|
||||
this.container.removeObject(this.leftFoot);
|
||||
this.container.removeObject(this.rightFoot);
|
||||
private animateScaling(q: number) {
|
||||
this.head.radius = headRadius * q;
|
||||
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||
}
|
||||
|
||||
private freeFallCorpse(deltaTime: number) {
|
||||
const planets = this.movementWorld.groundsNear(
|
||||
this.center,
|
||||
boundRadius + settings.maxGravityDistance,
|
||||
);
|
||||
let grounded = false;
|
||||
for (const part of this.parts) {
|
||||
vec2.copy(part.velocity, this.bodyVelocity);
|
||||
part.applyForce(sumGravity(planets, part.center), deltaTime);
|
||||
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
|
||||
grounded = true;
|
||||
}
|
||||
}
|
||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,138 +1,72 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
applyForce,
|
||||
Circle,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
GameObject,
|
||||
resolveCircleMovement,
|
||||
serializesTo,
|
||||
} from 'shared';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { ReactToCollisionCommand } from '../commands/commands';
|
||||
|
||||
@serializesTo(Circle)
|
||||
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
|
||||
readonly canCollide = true;
|
||||
readonly canMove = true;
|
||||
import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { Physical } from '../physics/physical';
|
||||
|
||||
export class CirclePhysical extends Circle implements Physical {
|
||||
public readonly canCollide = true;
|
||||
public velocity = vec2.create();
|
||||
public lastNormal = vec2.fromValues(0, 1);
|
||||
|
||||
private _boundingBox: BoundingBox;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
private readonly box = new BoundingBox();
|
||||
|
||||
constructor(
|
||||
private _center: vec2,
|
||||
private _radius: number,
|
||||
public owner: GameObject,
|
||||
center: vec2,
|
||||
radius: number,
|
||||
public readonly owner: Physical,
|
||||
private readonly container: PhysicalContainer,
|
||||
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
|
||||
// interface the movement simulation operates on.
|
||||
public readonly restitution = 0,
|
||||
) {
|
||||
super();
|
||||
this._boundingBox = new BoundingBox();
|
||||
this.syncBoundingBox();
|
||||
super(center, radius);
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
return this._boundingBox;
|
||||
}
|
||||
|
||||
public get center(): vec2 {
|
||||
return this._center;
|
||||
}
|
||||
|
||||
public set center(value: vec2) {
|
||||
this._center = value;
|
||||
this.syncBoundingBox();
|
||||
}
|
||||
|
||||
public onCollision(c: ReactToCollisionCommand) {
|
||||
this.owner.handleCommand(c);
|
||||
public get boundingBox(): BoundingBox {
|
||||
this.box.xMin = this.center[0] - this.radius;
|
||||
this.box.xMax = this.center[0] + this.radius;
|
||||
this.box.yMin = this.center[1] - this.radius;
|
||||
this.box.yMax = this.center[1] + this.radius;
|
||||
return this.box;
|
||||
}
|
||||
|
||||
public get gameObject(): GameObject {
|
||||
return this.owner;
|
||||
return this.owner.gameObject;
|
||||
}
|
||||
|
||||
public get radius(): number {
|
||||
return this._radius;
|
||||
}
|
||||
|
||||
public set radius(value: number) {
|
||||
this._radius = value;
|
||||
this.syncBoundingBox();
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return vec2.distance(target, this.center) - this.radius;
|
||||
}
|
||||
|
||||
public syncBoundingBox() {
|
||||
this._boundingBox.xMin = this.center.x - this._radius;
|
||||
this._boundingBox.xMax = this.center.x + this._radius;
|
||||
this._boundingBox.yMin = this.center.y - this._radius;
|
||||
this._boundingBox.yMax = this.center.y + this._radius;
|
||||
public onCollision(other: GameObject) {
|
||||
this.owner.onCollision?.(other);
|
||||
}
|
||||
|
||||
public applyForce(force: vec2, timeInSeconds: number) {
|
||||
applyForce(this, force, timeInSeconds);
|
||||
}
|
||||
|
||||
// Delegates to the shared resolveCircleMovement so the server integrates with
|
||||
// the exact same geometry the client predictor runs — no parallel copy to
|
||||
// keep in sync. `possibleIntersectors` lets a caller that already broadphased
|
||||
// avoid a second container query; otherwise it is self-gathered.
|
||||
public stepManually(
|
||||
deltaTimeInSeconds: number,
|
||||
possibleIntersectors?: Array<Physical>,
|
||||
): {
|
||||
hitObject: GameObject | undefined;
|
||||
velocity: vec2;
|
||||
} {
|
||||
): GameObject | undefined {
|
||||
const intersecting = (
|
||||
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
||||
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||
|
||||
const { hitObject, velocity } = resolveCircleMovement(
|
||||
const hit = resolveCircleMovement(
|
||||
this,
|
||||
deltaTimeInSeconds,
|
||||
intersecting,
|
||||
(intersected) => {
|
||||
const physical = intersected as Physical;
|
||||
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
||||
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
||||
physical.onCollision?.(this.gameObject);
|
||||
this.onCollision(physical.gameObject);
|
||||
},
|
||||
);
|
||||
) as Physical | undefined;
|
||||
|
||||
this.syncBoundingBox();
|
||||
|
||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||
return hit?.gameObject;
|
||||
}
|
||||
|
||||
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||
const sweep = vec2.length(
|
||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||
);
|
||||
// Grow the query box, not this.radius: mutating the radius would re-register
|
||||
// the body's extent mid-tick via the setter.
|
||||
const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
|
||||
return this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
|
||||
BoundingBox.ofCircle(this.center, this.radius + sweep),
|
||||
);
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
const { center, radius } = this;
|
||||
return [center, radius];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,15 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { Circle, LampBase, settings, id, serializesTo } from 'shared';
|
||||
import { LampBase, settings, id } from 'shared';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { Physical } from '../physics/physical';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||
|
||||
@serializesTo(LampBase)
|
||||
export class LampPhysical extends LampBase implements StaticPhysical {
|
||||
export class LampPhysical extends LampBase implements Physical {
|
||||
public readonly canCollide = false;
|
||||
public readonly canMove = false;
|
||||
public readonly boundingBox: BoundingBox;
|
||||
|
||||
constructor(center: vec2, color: vec3, lightness: number) {
|
||||
super(id(), center, color, lightness);
|
||||
}
|
||||
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
this._boundingBox = getBoundingBoxOfCircle(
|
||||
new Circle(this.center, settings.lightCutoffDistance),
|
||||
);
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
|
|
|
|||
|
|
@ -1,145 +1,90 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import {
|
||||
Circle,
|
||||
clamp01,
|
||||
id,
|
||||
mix,
|
||||
Random,
|
||||
serializesTo,
|
||||
settings,
|
||||
PlanetBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
planetDistance,
|
||||
planetGravity,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
} from 'shared';
|
||||
import {
|
||||
GeneratePointsCommand,
|
||||
AnnounceCommand,
|
||||
StepCommand,
|
||||
} from '../commands/commands';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StaticPhysical } from '../physics/physicals/static-physical';
|
||||
import { GameEvents } from '../game-events';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { Physical } from '../physics/physical';
|
||||
import { LampPhysical } from './lamp-physical';
|
||||
import type { CharacterPhysical } from './character-physical';
|
||||
|
||||
@serializesTo(PlanetBase)
|
||||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = false;
|
||||
// Marks this as standable ground for the shared movement simulation.
|
||||
public readonly isGround = true;
|
||||
export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
|
||||
objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
|
||||
export class PlanetPhysical extends PlanetBase implements Physical {
|
||||
public readonly boundingBox: BoundingBox;
|
||||
public readonly sizePointMultiplier: number;
|
||||
|
||||
// Authoritative spin angle, streamed to the client so the rendered outline
|
||||
// and this collision polygon turn as one. cos/sin are memoised: distance()
|
||||
// is called many times per tick by the raymarcher and the angle rarely
|
||||
// changes between those calls.
|
||||
private rotation = 0;
|
||||
private readonly rotationSpeed: number;
|
||||
private cachedRotation = Number.NaN;
|
||||
private cosRotation = 1;
|
||||
private sinRotation = 0;
|
||||
|
||||
private _boundingBox?: ImmutableBoundingBox;
|
||||
|
||||
private readonly lamps: Array<LampPhysical> = [];
|
||||
|
||||
private lastTeam: CharacterTeam = CharacterTeam.neutral;
|
||||
|
||||
// Characters standing on the planet this tick. Filled by registerPresence as
|
||||
// each grounded character steps, drained when the planet resolves capture in
|
||||
// its own step().
|
||||
private presentCharacters: Array<CharacterPhysical> = [];
|
||||
private isContested = false;
|
||||
private timeSinceLastPointGeneration = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
};
|
||||
|
||||
public addLamp(lamp: LampPhysical) {
|
||||
this.lamps.push(lamp);
|
||||
}
|
||||
|
||||
constructor(vertices: Array<vec2>, isKeystone = false) {
|
||||
constructor(
|
||||
vertices: Array<vec2>,
|
||||
isKeystone: boolean,
|
||||
private readonly game: GameEvents,
|
||||
) {
|
||||
super(id(), vertices, 0.5, isKeystone);
|
||||
|
||||
const sizeClass = clamp01(
|
||||
(this.radius - settings.planetMinReferenceRadius) /
|
||||
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
|
||||
);
|
||||
|
||||
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
|
||||
|
||||
this.rotationSpeed =
|
||||
this.angularVelocity =
|
||||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
||||
this.boundingBox = BoundingBox.ofCircle(
|
||||
this.center,
|
||||
Math.max(...vertices.map((v) => vec2.distance(this.center, v))),
|
||||
);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
public addLamp(lamp: LampPhysical) {
|
||||
this.lamps.push(lamp);
|
||||
}
|
||||
|
||||
public registerPresence(character: CharacterPhysical) {
|
||||
this.presentCharacters.push(character);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
// The shared planet outline — the same function the client predictor
|
||||
// collides against, which is what lets prediction reconcile.
|
||||
this.syncRotationTrigonometry();
|
||||
return planetDistance(
|
||||
target,
|
||||
this.vertices,
|
||||
this.center,
|
||||
this.cosRotation,
|
||||
this.sinRotation,
|
||||
);
|
||||
}
|
||||
|
||||
private syncRotationTrigonometry() {
|
||||
if (this.rotation !== this.cachedRotation) {
|
||||
this.cachedRotation = this.rotation;
|
||||
this.cosRotation = Math.cos(this.rotation);
|
||||
this.sinRotation = Math.sin(this.rotation);
|
||||
}
|
||||
}
|
||||
|
||||
// Signed angular velocity in rad/s, exposed so a character can ride the
|
||||
// planet's spin (see carryWithRotatingPlanet in shared).
|
||||
public get angularVelocity(): number {
|
||||
return this.rotationSpeed;
|
||||
}
|
||||
|
||||
private timeSinceLastPointGeneration = 0;
|
||||
private getPoints(game: CommandReceiver) {
|
||||
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
|
||||
this.timeSinceLastPointGeneration = 0;
|
||||
|
||||
const value = Math.round(
|
||||
settings.planetPointGenerationValue * this.sizePointMultiplier,
|
||||
);
|
||||
game.handleCommand(
|
||||
new GeneratePointsCommand(
|
||||
this.team === CharacterTeam.blue ? value : 0,
|
||||
this.team === CharacterTeam.red ? value : 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.advanceRotation(deltaTimeInSeconds);
|
||||
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
||||
|
||||
// In reverse order, so that teams can achieve a 100% control.
|
||||
this.getPoints(game);
|
||||
this.generatePoints();
|
||||
this.resolveCapture(deltaTimeInSeconds);
|
||||
this.detectFlip(game);
|
||||
this.detectFlip();
|
||||
|
||||
this.presentCharacters = [];
|
||||
this.presentCharacters.length = 0;
|
||||
}
|
||||
|
||||
private generatePoints() {
|
||||
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
|
||||
this.timeSinceLastPointGeneration = 0;
|
||||
this.awardToOwner(settings.planetPointGenerationValue);
|
||||
}
|
||||
}
|
||||
|
||||
private awardToOwner(base: number) {
|
||||
const value = Math.round(base * this.sizePointMultiplier);
|
||||
this.game.addPoints(
|
||||
this.team === CharacterTeam.blue ? value : 0,
|
||||
this.team === CharacterTeam.red ? value : 0,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCapture(deltaTime: number) {
|
||||
|
|
@ -175,7 +120,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
}
|
||||
}
|
||||
|
||||
private detectFlip(game: CommandReceiver) {
|
||||
private detectFlip() {
|
||||
const currentTeam = this.team;
|
||||
if (currentTeam === this.lastTeam) {
|
||||
return;
|
||||
|
|
@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
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.blue ? reward : 0,
|
||||
currentTeam === CharacterTeam.red ? reward : 0,
|
||||
),
|
||||
this.remoteCall(
|
||||
'generatedPoints',
|
||||
Math.round(settings.captureFlipPointReward * this.sizePointMultiplier),
|
||||
);
|
||||
this.awardToOwner(settings.captureFlipPointReward);
|
||||
|
||||
if (this.isKeystone) {
|
||||
game.handleCommand(
|
||||
new AnnounceCommand(
|
||||
this.game.announce(
|
||||
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -206,22 +144,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
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 {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||
// Stream the spin rate as the rate-of-change so the client can keep the
|
||||
// angle moving when snapshots run late (see planet-view.ts).
|
||||
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
|
||||
]);
|
||||
}
|
||||
|
||||
public takeControl(team: CharacterTeam, deltaTime: number) {
|
||||
private takeControl(team: CharacterTeam, deltaTime: number) {
|
||||
if (team === CharacterTeam.blue) {
|
||||
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||
} else if (team === CharacterTeam.red) {
|
||||
|
|
@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
-Math.sign(this.ownership - 0.5) *
|
||||
(0.5 / settings.loseControlTimeInSeconds) *
|
||||
deltaTime;
|
||||
if (
|
||||
(previous < 0.5 && this.ownership > 0.5) ||
|
||||
(previous > 0.5 && this.ownership < 0.5)
|
||||
) {
|
||||
if ((previous - 0.5) * (this.ownership - 0.5) < 0) {
|
||||
this.ownership = 0.5;
|
||||
}
|
||||
}
|
||||
|
|
@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
this.ownership = clamp01(this.ownership);
|
||||
}
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
if (!this._boundingBox) {
|
||||
// The polygon spins about its centre (see distance), so this static box
|
||||
// has to cover every orientation, not just the spawn-time one: take the
|
||||
// circumscribed circle around the rotation centre.
|
||||
const maxVertexDistance = this.vertices.reduce(
|
||||
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
|
||||
0,
|
||||
);
|
||||
|
||||
this._boundingBox = getBoundingBoxOfCircle(
|
||||
new Circle(this.center, maxVertexDistance),
|
||||
);
|
||||
}
|
||||
|
||||
return this._boundingBox;
|
||||
}
|
||||
|
||||
// GroundSurface gravity, shared with the client predictor.
|
||||
public gravityAt(position: vec2): vec2 {
|
||||
return planetGravity(this.center, this.radius, position);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||
new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,55 +2,42 @@ import { vec2 } from 'gl-matrix';
|
|||
import {
|
||||
id,
|
||||
settings,
|
||||
serializesTo,
|
||||
ProjectileBase,
|
||||
CharacterTeam,
|
||||
PropertyUpdatesForObject,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
Circle,
|
||||
marchCircle,
|
||||
GameObject,
|
||||
evaluateSdf,
|
||||
sumGravity,
|
||||
} from 'shared';
|
||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { CirclePhysical } from './circle-physical';
|
||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { Physical } from '../physics/physical';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { CharacterPhysical } from './character-physical';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
|
||||
import { planetsIn } from './planet-physical';
|
||||
|
||||
@serializesTo(ProjectileBase)
|
||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
||||
export class ProjectilePhysical extends ProjectileBase implements Physical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = true;
|
||||
public readonly object: CirclePhysical;
|
||||
|
||||
private isDestroyed = false;
|
||||
private bounceCount = 0;
|
||||
|
||||
public object: CirclePhysical;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[StepCommand.type]: this.handleStep.bind(this),
|
||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
||||
};
|
||||
|
||||
constructor(
|
||||
center: vec2,
|
||||
radius: number,
|
||||
public strength: number,
|
||||
team: CharacterTeam,
|
||||
private velocity: vec2,
|
||||
velocity: vec2,
|
||||
public readonly originator: CharacterPhysical,
|
||||
readonly container: PhysicalContainer,
|
||||
// Normalised charge (0..1) of the shot that fired this; the victim scales
|
||||
// hit/kill feedback and the death fling by it.
|
||||
private readonly container: PhysicalContainer,
|
||||
public readonly charge: number = 0,
|
||||
) {
|
||||
super(id(), center, radius, team, strength);
|
||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
||||
|
||||
this.moveOutsideOfObject();
|
||||
this.object = new CirclePhysical(this.center, radius, this, container, 0.9);
|
||||
vec2.copy(this.object.velocity, velocity);
|
||||
this.moveOutsideFriendlyBodies();
|
||||
}
|
||||
|
||||
public get isAlive(): boolean {
|
||||
|
|
@ -58,35 +45,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
}
|
||||
|
||||
public get direction(): vec2 {
|
||||
const direction = vec2.clone(this.velocity);
|
||||
const direction = vec2.clone(this.object.velocity);
|
||||
return vec2.length(direction) > 0
|
||||
? vec2.normalize(direction, direction)
|
||||
: vec2.fromValues(0, -1);
|
||||
}
|
||||
|
||||
private moveOutsideOfObject() {
|
||||
let wasCollision = true;
|
||||
const delta = vec2.scale(
|
||||
vec2.create(),
|
||||
vec2.normalize(vec2.create(), this.velocity),
|
||||
10,
|
||||
);
|
||||
// Bounded: each pass advances by `delta`, but a degenerate direction would
|
||||
// otherwise spin here forever inside the physics tick.
|
||||
let passes = 0;
|
||||
while (wasCollision && passes++ < 32) {
|
||||
const intersecting = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
||||
wasCollision = hitSurface;
|
||||
this.object.syncBoundingBox();
|
||||
}
|
||||
}
|
||||
|
||||
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
|
||||
// as it moves.
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
public get boundingBox(): BoundingBox {
|
||||
return this.object.boundingBox;
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
return this.object.distance(target);
|
||||
}
|
||||
|
||||
private moveOutsideFriendlyBodies() {
|
||||
const step = vec2.scale(vec2.create(), this.direction, 10);
|
||||
for (let pass = 0; pass < 32; pass++) {
|
||||
vec2.add(this.center, this.center, step);
|
||||
const friendly = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((o) => o instanceof CharacterPhysical && o.team === this.team);
|
||||
if (evaluateSdf(this.center, friendly) >= this.radius) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
if (!this.isDestroyed) {
|
||||
this.isDestroyed = true;
|
||||
|
|
@ -105,7 +83,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
}
|
||||
}
|
||||
|
||||
public onCollision({ other }: ReactToCollisionCommand) {
|
||||
public onCollision(other: GameObject) {
|
||||
if (
|
||||
!(other instanceof CharacterPhysical && other.team === this.team) &&
|
||||
this.bounceCount++ === settings.projectileMaxBounceCount
|
||||
|
|
@ -116,15 +94,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
|
||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||
return new PropertyUpdatesForObject(this.id, [
|
||||
new UpdatePropertyCommand('center', this.center, this.velocity),
|
||||
new UpdatePropertyCommand('center', this.center, this.object.velocity),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
||||
this.advance(deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
private advance(deltaTimeInSeconds: number) {
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
super.step(deltaTimeInSeconds);
|
||||
|
||||
if (this.strength <= 0) {
|
||||
|
|
@ -132,24 +106,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
return;
|
||||
}
|
||||
|
||||
// This single broadphase is reused for the step below: the gravity radius
|
||||
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
|
||||
// the swept-collision box and the step needn't query the container again.
|
||||
const intersecting = this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(
|
||||
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
|
||||
),
|
||||
BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance),
|
||||
);
|
||||
|
||||
vec2.scaleAndAdd(
|
||||
this.velocity,
|
||||
this.velocity,
|
||||
forceAtPosition(this.center, intersecting),
|
||||
this.object.velocity,
|
||||
this.object.velocity,
|
||||
sumGravity(planetsIn(intersecting), this.center),
|
||||
settings.projectileGravityScale * deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
vec2.copy(this.object.velocity, this.velocity);
|
||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
|
||||
vec2.copy(this.velocity, velocity);
|
||||
this.object.stepManually(deltaTimeInSeconds, intersecting);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,28 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { BoundingBoxBase } from './bounding-box-base';
|
||||
|
||||
export class BoundingBox extends BoundingBoxBase {
|
||||
public get xMin(): number {
|
||||
return this._xMin;
|
||||
export class BoundingBox {
|
||||
constructor(
|
||||
public xMin = 0,
|
||||
public xMax = 0,
|
||||
public yMin = 0,
|
||||
public yMax = 0,
|
||||
) {}
|
||||
|
||||
public static ofCircle(center: vec2, radius: number): BoundingBox {
|
||||
return new BoundingBox(
|
||||
center[0] - radius,
|
||||
center[0] + radius,
|
||||
center[1] - radius,
|
||||
center[1] + radius,
|
||||
);
|
||||
}
|
||||
|
||||
public set xMin(value: number) {
|
||||
this._xMin = value;
|
||||
}
|
||||
|
||||
public set xMax(value: number) {
|
||||
this._xMax = value;
|
||||
}
|
||||
|
||||
public get xMax(): number {
|
||||
return this._xMax;
|
||||
}
|
||||
|
||||
public set yMin(value: number) {
|
||||
this._yMin = value;
|
||||
}
|
||||
|
||||
public get yMin(): number {
|
||||
return this._yMin;
|
||||
}
|
||||
|
||||
public set yMax(value: number) {
|
||||
this._yMax = value;
|
||||
}
|
||||
|
||||
public get yMax(): number {
|
||||
return this._yMax;
|
||||
}
|
||||
|
||||
public get topLeft(): vec2 {
|
||||
return vec2.fromValues(this._xMin, this._yMax);
|
||||
}
|
||||
|
||||
public set topLeft(value: vec2) {
|
||||
this._xMin = value.x;
|
||||
this._yMax = value.y;
|
||||
}
|
||||
|
||||
public set size(value: vec2) {
|
||||
this._xMax = this.xMin + value.x;
|
||||
this._yMin = this.yMax - value.y;
|
||||
}
|
||||
|
||||
public get size(): vec2 {
|
||||
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
|
||||
public intersects(other: BoundingBox): boolean {
|
||||
return (
|
||||
this.xMin < other.xMax &&
|
||||
this.xMax > other.xMin &&
|
||||
this.yMin < other.yMax &&
|
||||
this.yMax > other.yMin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,29 @@
|
|||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
||||
import { BoundingBoxList } from './bounding-box-list';
|
||||
import { BoundingBoxTree } from './bounding-box-tree';
|
||||
import { Physical } from '../physicals/physical';
|
||||
import { StaticPhysical } from '../physicals/static-physical';
|
||||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
||||
import { Command, CommandReceiver } from 'shared';
|
||||
import { GameEvents } from '../game-events';
|
||||
import { BoundingBox } from './bounding-box';
|
||||
import { Physical } from './physical';
|
||||
|
||||
export class PhysicalContainer extends CommandReceiver {
|
||||
private isTreeInitialized = false;
|
||||
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
|
||||
private staticBoundingBoxes = new BoundingBoxTree();
|
||||
private dynamicBoundingBoxes = new BoundingBoxList();
|
||||
export class PhysicalContainer {
|
||||
private objects: Array<Physical> = [];
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
this.objects.forEach((o) => o.handleCommand(c));
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
|
||||
this.isTreeInitialized = true;
|
||||
}
|
||||
constructor(public readonly game: GameEvents) {}
|
||||
|
||||
public addObject(physical: Physical) {
|
||||
this.objects.push(physical);
|
||||
|
||||
if (physical.canMove) {
|
||||
this.dynamicBoundingBoxes.insert(physical);
|
||||
} else {
|
||||
if (!this.isTreeInitialized) {
|
||||
this.staticBoundingBoxesWaitList.push(physical);
|
||||
} else {
|
||||
this.staticBoundingBoxes.insert(physical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeObject(object: DynamicPhysical) {
|
||||
this.objects = this.objects.filter((p) => p !== object);
|
||||
this.dynamicBoundingBoxes.remove(object);
|
||||
public removeObject(physical: Physical) {
|
||||
this.objects = this.objects.filter((o) => o !== physical);
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.objects.forEach((o) => o.step?.(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public resetRemoteCalls() {
|
||||
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
|
||||
}
|
||||
|
||||
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
|
||||
return [
|
||||
...this.staticBoundingBoxes.findIntersecting(box),
|
||||
...this.dynamicBoundingBoxes.findIntersecting(box),
|
||||
];
|
||||
public findIntersecting(box: BoundingBox): Array<Physical> {
|
||||
return this.objects.filter((o) => o.boundingBox.intersects(box));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { DynamicPhysical } from './dynamic-physical';
|
||||
import { StaticPhysical } from './static-physical';
|
||||
import { vec2 } from 'gl-matrix';
|
||||
import { GameObject } from 'shared';
|
||||
import { BoundingBox } from './bounding-box';
|
||||
|
||||
export type Physical = StaticPhysical | DynamicPhysical;
|
||||
export interface Physical {
|
||||
readonly canCollide: boolean;
|
||||
readonly boundingBox: BoundingBox;
|
||||
readonly gameObject: GameObject;
|
||||
|
||||
distance(target: vec2): number;
|
||||
step?(deltaTimeInSeconds: number): void;
|
||||
onCollision?(other: GameObject): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
PlayerInformation,
|
||||
settings,
|
||||
Circle,
|
||||
Random,
|
||||
MoveActionCommand,
|
||||
CharacterTeam,
|
||||
Id,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
|
||||
import { ProjectilePhysical } from '../objects/projectile-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { Physical } from '../physics/physical';
|
||||
|
||||
const npcTuning = {
|
||||
const tuning = {
|
||||
planIntervalSeconds: 1,
|
||||
shootIntervalSeconds: 1.5,
|
||||
reactionIntervalSeconds: 1 / 10,
|
||||
|
|
@ -40,7 +32,6 @@ const npcTuning = {
|
|||
|
||||
dodgeThreatRange: 450,
|
||||
dodgeApproachDot: 0.6,
|
||||
|
||||
dodgeBaseChance: 0.25,
|
||||
dodgeAggressionChance: 0.35,
|
||||
dodgeCommitSeconds: 0.35,
|
||||
|
|
@ -64,27 +55,33 @@ const npcTuning = {
|
|||
leapChancePerSecond: 0.35,
|
||||
};
|
||||
|
||||
interface Enemy {
|
||||
character: CharacterPhysical;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export class NPC extends PlayerBase {
|
||||
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
||||
private timeSinceLastPlan = 10000;
|
||||
private timeSinceLastShoot = 10000;
|
||||
private isWandering = false;
|
||||
private movement = vec2.create();
|
||||
private timeSinceLastPlan = Infinity;
|
||||
private timeSinceLastShoot = Infinity;
|
||||
private timeSinceObserve = Infinity;
|
||||
private timeSinceLastWanderingConsideration = 0;
|
||||
private isWandering = false;
|
||||
private isComingBack = false;
|
||||
|
||||
private readonly aggression = Random.getRandomInRange(
|
||||
npcTuning.aggressionMin,
|
||||
npcTuning.aggressionMax,
|
||||
tuning.aggressionMin,
|
||||
tuning.aggressionMax,
|
||||
);
|
||||
|
||||
private aimTargetId: Id = null;
|
||||
private aimTargetId: Id | null = 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(
|
||||
|
|
@ -95,7 +92,6 @@ export class NPC extends PlayerBase {
|
|||
) {
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
|
|
@ -106,44 +102,40 @@ export class NPC extends PlayerBase {
|
|||
|
||||
if (
|
||||
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
||||
npcTuning.wanderReconsiderSeconds
|
||||
tuning.wanderReconsiderSeconds
|
||||
) {
|
||||
this.timeSinceLastWanderingConsideration = 0;
|
||||
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
|
||||
this.isWandering = Random.getRandom() < tuning.wanderProbability;
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
|
||||
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) {
|
||||
this.timeSinceLastPlan = 0;
|
||||
this.plan();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
||||
) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
||||
}
|
||||
|
||||
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
|
||||
const movement = this.decideMovement(this.nearObjects);
|
||||
character.handleMovementAction(new MoveActionCommand(movement));
|
||||
|
||||
if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(tuning.reactionObserveRadius);
|
||||
this.movement = this.decideMovement(character, this.nearObjects);
|
||||
character.setMoveDirection(this.movement);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isComingBack &&
|
||||
character.groundPlanet &&
|
||||
vec2.length(movement) > 0 &&
|
||||
vec2.length(this.movement) > 0 &&
|
||||
Random.getRandom() <
|
||||
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||
tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||
) {
|
||||
character.leap();
|
||||
}
|
||||
|
||||
if (
|
||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
||||
) {
|
||||
if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
|
||||
this.timeSinceLastShoot = 0;
|
||||
this.tryShoot(this.nearObjects);
|
||||
this.tryShoot(character, this.nearObjects);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,20 +151,18 @@ export class NPC extends PlayerBase {
|
|||
}
|
||||
this.isComingBack = false;
|
||||
|
||||
const nearObjects = this.observe(npcTuning.planScanRadius);
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
|
||||
const nearest = enemies[0];
|
||||
const nearObjects = this.observe(tuning.planScanRadius);
|
||||
const nearest = this.enemiesByDistance(nearObjects)[0];
|
||||
if (nearest) {
|
||||
const fleeRange =
|
||||
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
|
||||
tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression);
|
||||
if (nearest.distance < fleeRange) {
|
||||
vec2.subtract(this.direction, this.center, nearest.character.center);
|
||||
return;
|
||||
}
|
||||
|
||||
const chaseRange =
|
||||
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
|
||||
tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
|
||||
if (nearest.distance < chaseRange) {
|
||||
vec2.subtract(this.direction, nearest.character.center, this.center);
|
||||
return;
|
||||
|
|
@ -182,7 +172,7 @@ export class NPC extends PlayerBase {
|
|||
if (!this.isWandering) {
|
||||
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
|
||||
if (planet) {
|
||||
vec2.subtract(this.direction, planet.planet.center, this.center);
|
||||
vec2.subtract(this.direction, planet.center, this.center);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -191,11 +181,14 @@ export class NPC extends PlayerBase {
|
|||
this.direction,
|
||||
this.direction,
|
||||
vec2.create(),
|
||||
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
|
||||
Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn),
|
||||
);
|
||||
}
|
||||
|
||||
private decideMovement(nearObjects: Array<Physical>): vec2 {
|
||||
private decideMovement(
|
||||
character: CharacterPhysical,
|
||||
nearObjects: Array<Physical>,
|
||||
): vec2 {
|
||||
if (this.dodgeCommitRemaining > 0) {
|
||||
return vec2.clone(this.dodgeDirection);
|
||||
}
|
||||
|
|
@ -204,23 +197,20 @@ export class NPC extends PlayerBase {
|
|||
if (dodge) {
|
||||
if (
|
||||
Random.getRandom() <
|
||||
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
|
||||
tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
|
||||
) {
|
||||
vec2.copy(this.dodgeDirection, dodge);
|
||||
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
|
||||
this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
|
||||
return vec2.clone(this.dodgeDirection);
|
||||
}
|
||||
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
|
||||
this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
const planet = this.character!.groundPlanet;
|
||||
const planet = character.groundPlanet;
|
||||
if (planet && planet.team !== this.team) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
if (
|
||||
enemies.length === 0 ||
|
||||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
|
||||
) {
|
||||
if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
|
||||
return vec2.create();
|
||||
}
|
||||
}
|
||||
|
|
@ -239,12 +229,12 @@ export class NPC extends PlayerBase {
|
|||
}
|
||||
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
|
||||
const distance = vec2.length(toMe);
|
||||
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
|
||||
if (distance > tuning.dodgeThreatRange || distance === 0) {
|
||||
continue;
|
||||
}
|
||||
vec2.normalize(toMe, toMe);
|
||||
if (
|
||||
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
|
||||
vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
|
||||
distance < threatDistance
|
||||
) {
|
||||
threatDistance = distance;
|
||||
|
|
@ -261,14 +251,11 @@ export class NPC extends PlayerBase {
|
|||
if (vec2.dot(perpendicular, toMe) < 0) {
|
||||
vec2.negate(perpendicular, perpendicular);
|
||||
}
|
||||
vec2.normalize(perpendicular, perpendicular);
|
||||
|
||||
return perpendicular;
|
||||
return vec2.normalize(perpendicular, perpendicular);
|
||||
}
|
||||
|
||||
private tryShoot(nearObjects: Array<Physical>) {
|
||||
const enemies = this.enemiesByDistance(nearObjects);
|
||||
const visible = enemies.find((e) =>
|
||||
private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
|
||||
const visible = this.enemiesByDistance(nearObjects).find((e) =>
|
||||
this.hasLineOfSightTo(e.character.center, nearObjects),
|
||||
);
|
||||
if (!visible) {
|
||||
|
|
@ -276,71 +263,69 @@ export class NPC extends PlayerBase {
|
|||
return;
|
||||
}
|
||||
|
||||
const target = visible.character;
|
||||
const distance = visible.distance;
|
||||
const { character: target, distance } = visible;
|
||||
|
||||
const velocity = vec2.create();
|
||||
if (this.aimTargetId === target.id) {
|
||||
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
|
||||
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
|
||||
vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds);
|
||||
}
|
||||
this.aimTargetId = target.id;
|
||||
vec2.copy(this.aimTargetLastPosition, target.center);
|
||||
|
||||
if (
|
||||
Random.getRandom() >
|
||||
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
|
||||
tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const charge =
|
||||
distance > npcTuning.chargeRangeThreshold &&
|
||||
distance > tuning.chargeRangeThreshold &&
|
||||
Random.getRandom() <
|
||||
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
||||
? Random.getRandomInRange(npcTuning.chargeMin, 1)
|
||||
tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
|
||||
? Random.getRandomInRange(tuning.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 aim = vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
target.center,
|
||||
velocity,
|
||||
distance / projectileSpeed,
|
||||
);
|
||||
|
||||
const spread =
|
||||
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
|
||||
(npcTuning.spreadAggressionFalloff - this.aggression);
|
||||
aim.x += Random.getRandomInRange(-spread, spread);
|
||||
aim.y += Random.getRandomInRange(-spread, spread);
|
||||
(tuning.spreadBase + distance * tuning.spreadPerDistance) *
|
||||
(tuning.spreadAggressionFalloff - this.aggression);
|
||||
aim[0] += Random.getRandomInRange(-spread, spread);
|
||||
aim[1] += Random.getRandomInRange(-spread, spread);
|
||||
|
||||
this.character!.shootTowards(aim, charge);
|
||||
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);
|
||||
}
|
||||
}
|
||||
const planets = planetsIn(nearObjects);
|
||||
if (planets.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const direction = vec2.subtract(vec2.create(), target, this.center);
|
||||
const totalDistance = vec2.length(direction);
|
||||
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
|
||||
if (totalDistance <= tuning.lineOfSightStartOffset) {
|
||||
return true;
|
||||
}
|
||||
vec2.normalize(direction, direction);
|
||||
|
||||
let traveled = npcTuning.lineOfSightStartOffset;
|
||||
let traveled = tuning.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) {
|
||||
if (sdf < tuning.lineOfSightClearance) {
|
||||
return false;
|
||||
}
|
||||
traveled += sdf;
|
||||
|
|
@ -351,45 +336,34 @@ export class NPC extends PlayerBase {
|
|||
|
||||
private observe(radius: number): Array<Physical> {
|
||||
return this.objectContainer.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(this.center, radius)),
|
||||
BoundingBox.ofCircle(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 }> = [];
|
||||
private enemiesByDistance(nearObjects: Array<Physical>): Array<Enemy> {
|
||||
const enemies: Array<Enemy> = [];
|
||||
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)
|
||||
c.team !== this.team
|
||||
) {
|
||||
seen.add(c);
|
||||
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
|
||||
}
|
||||
}
|
||||
enemies.sort((a, b) => a.distance - b.distance);
|
||||
return enemies;
|
||||
return enemies.sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
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;
|
||||
): Array<PlanetPhysical> {
|
||||
return planetsIn(nearObjects)
|
||||
.filter((p) => p.team !== this.team)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CommandReceiver,
|
||||
Circle,
|
||||
PlayerInformation,
|
||||
CharacterTeam,
|
||||
Random,
|
||||
|
|
@ -10,53 +9,64 @@ import {
|
|||
boundRadius,
|
||||
evaluateSdf,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
||||
const maximumNameLength = 40;
|
||||
|
||||
export interface Score {
|
||||
kills: number;
|
||||
deaths: number;
|
||||
}
|
||||
|
||||
export abstract class PlayerBase extends CommandReceiver {
|
||||
public character?: CharacterPhysical | null;
|
||||
public character: CharacterPhysical | null = null;
|
||||
public center: vec2 = vec2.create();
|
||||
|
||||
protected sumKills = 0;
|
||||
protected sumDeaths = 0;
|
||||
protected lastCharacter?: CharacterPhysical;
|
||||
protected timeUntilRespawn = 0;
|
||||
private kills: number;
|
||||
private deaths: number;
|
||||
|
||||
constructor(
|
||||
protected readonly playerInfo: PlayerInformation,
|
||||
protected readonly playerContainer: PlayerContainer,
|
||||
protected readonly objectContainer: PhysicalContainer,
|
||||
public readonly team: CharacterTeam,
|
||||
score: Score = { kills: 0, deaths: 0 },
|
||||
) {
|
||||
super();
|
||||
this.kills = score.kills;
|
||||
this.deaths = score.deaths;
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
public get score(): Score {
|
||||
return {
|
||||
kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills,
|
||||
deaths: this.deaths,
|
||||
};
|
||||
}
|
||||
|
||||
protected createCharacter(): CharacterPhysical {
|
||||
this.kills = this.lastCharacter?.killCount ?? this.kills;
|
||||
this.character = new CharacterPhysical(
|
||||
// Coerce, don't just truncate: input is untrusted (from deserialize), and
|
||||
// arrays have `.slice()` too — a non-string name would throw inside the
|
||||
// reviver and kill the recipient's whole message batch.
|
||||
sanitizeName(this.playerInfo.name, maximumNameLength),
|
||||
this.sumKills,
|
||||
this.sumDeaths,
|
||||
this.kills,
|
||||
this.deaths,
|
||||
this.team,
|
||||
this.objectContainer,
|
||||
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
|
||||
);
|
||||
|
||||
this.objectContainer.addObject(this.character);
|
||||
this.center = this.character.center;
|
||||
return this.character;
|
||||
}
|
||||
|
||||
public abstract step(deltaTimeInSeconds: number): void;
|
||||
|
||||
/**
|
||||
* Shared death/respawn cycle. Returns the living character to act with this
|
||||
* tick (or undefined), so a subclass can use it directly without re-narrowing.
|
||||
*/
|
||||
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
|
||||
if (this.character) {
|
||||
this.center = this.character.center;
|
||||
|
|
@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
return this.character;
|
||||
}
|
||||
|
||||
this.sumDeaths++;
|
||||
this.sumKills = this.character.killCount;
|
||||
this.onCharacterDied(this.character);
|
||||
this.deaths++;
|
||||
this.lastCharacter = this.character;
|
||||
this.character = null;
|
||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||
this.onBeforeRespawn();
|
||||
this.createCharacter();
|
||||
this.center = this.character!.center;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Override hooks (no-ops by default).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected onCharacterDied(character: CharacterPhysical) {}
|
||||
protected onBeforeRespawn() {}
|
||||
|
||||
private findSpawnCenter(): vec2 {
|
||||
const planets = this.objectContainer
|
||||
.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
|
||||
)
|
||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
const planets = planetsIn(
|
||||
this.objectContainer.findIntersecting(
|
||||
BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2),
|
||||
),
|
||||
);
|
||||
|
||||
const friendly = planets.filter((p) => p.team === this.team);
|
||||
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
|
||||
|
|
@ -109,42 +111,32 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
);
|
||||
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);
|
||||
}
|
||||
|
||||
public restoreScore(kills: number, deaths: number) {
|
||||
this.sumKills = kills;
|
||||
this.sumDeaths = deaths;
|
||||
}
|
||||
|
||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
let rotation = 0;
|
||||
let radius = 0;
|
||||
// preferredCenter is a planet centre, the single worst place to give up on:
|
||||
// depenetrateCircle only runs four passes, and a body left that deep inside
|
||||
// the rock registers a zero-distance hit every march and never moves again.
|
||||
// So the roomiest point the spiral saw is kept as the fallback instead.
|
||||
let roomiestPosition = vec2.clone(preferredCenter);
|
||||
let roomiestClearance = -Infinity;
|
||||
for (let attempt = 0; attempt < 512; attempt++) {
|
||||
const playerPosition = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter.x,
|
||||
radius * Math.sin(rotation) + preferredCenter.y,
|
||||
const position = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter[0],
|
||||
radius * Math.sin(rotation) + preferredCenter[1],
|
||||
);
|
||||
|
||||
const playerBoundingCircle = new Circle(playerPosition, boundRadius);
|
||||
|
||||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
||||
const possibleIntersectors =
|
||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
||||
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors);
|
||||
if (clearance >= playerBoundingCircle.radius) {
|
||||
return playerPosition;
|
||||
const clearance = evaluateSdf(
|
||||
position,
|
||||
this.objectContainer.findIntersecting(
|
||||
BoundingBox.ofCircle(position, boundRadius),
|
||||
),
|
||||
);
|
||||
if (clearance >= boundRadius) {
|
||||
return position;
|
||||
}
|
||||
if (clearance > roomiestClearance) {
|
||||
roomiestClearance = clearance;
|
||||
roomiestPosition = playerPosition;
|
||||
roomiestPosition = position;
|
||||
}
|
||||
|
||||
rotation += Math.PI / 8;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
|
||||
import {
|
||||
CharacterTeam,
|
||||
PlayerInformation,
|
||||
Random,
|
||||
settings,
|
||||
Command,
|
||||
GameObject,
|
||||
PropertyUpdatesForObject,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { NPC } from './npc';
|
||||
import { Player } from './player';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { ServerFullError } from './server-full-error';
|
||||
import { PlayerBase, Score } from './player-base';
|
||||
|
||||
// Team and score carried over from the connection a reconnecting client is
|
||||
// replacing, so it resumes the match instead of starting from zero.
|
||||
export interface CarriedScore {
|
||||
export interface CarriedScore extends Score {
|
||||
team: CharacterTeam;
|
||||
kills: number;
|
||||
deaths: number;
|
||||
}
|
||||
|
||||
export class PlayerContainer {
|
||||
private _players: Array<Player> = [];
|
||||
private _npcs: Array<NPC> = [];
|
||||
private npcs: Array<NPC> = [];
|
||||
|
||||
constructor(
|
||||
private readonly objects: PhysicalContainer,
|
||||
|
|
@ -26,14 +29,14 @@ export class PlayerContainer {
|
|||
this.createNPCs();
|
||||
}
|
||||
|
||||
public createNPCs() {
|
||||
private createNPCs() {
|
||||
const newNpcCount = Math.min(
|
||||
this.playerMaxCount - this._players.length - this._npcs.length,
|
||||
this.npcMaxCount - this._npcs.length,
|
||||
this.playerMaxCount - this._players.length - this.npcs.length,
|
||||
this.npcMaxCount - this.npcs.length,
|
||||
);
|
||||
for (let i = 0; i < newNpcCount; i++) {
|
||||
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
||||
this._npcs.push(
|
||||
this.npcs.push(
|
||||
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
||||
);
|
||||
}
|
||||
|
|
@ -44,31 +47,20 @@ export class PlayerContainer {
|
|||
socket: Socket,
|
||||
carried?: CarriedScore,
|
||||
): Player {
|
||||
if (this._players.length >= this.playerMaxCount) {
|
||||
throw new ServerFullError();
|
||||
}
|
||||
|
||||
const team = carried ? carried.team : this.getTeamOfNextPlayer();
|
||||
|
||||
// Retire the bot before the player spawns: its body would otherwise push
|
||||
// the spawn search away from the position it picked.
|
||||
const npcToReplace =
|
||||
this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team);
|
||||
this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
|
||||
npcToReplace?.destroy();
|
||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||
this.npcs = this.npcs.filter((n) => n !== npcToReplace);
|
||||
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket, carried);
|
||||
this._players.push(player);
|
||||
|
||||
if (carried) {
|
||||
player.restoreScore(carried.kills, carried.deaths);
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public get players(): Array<PlayerBase> {
|
||||
return [...this._players, ...this._npcs];
|
||||
return [...this._players, ...this.npcs];
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
|
|
@ -79,20 +71,22 @@ export class PlayerContainer {
|
|||
return this._players.length >= this.playerMaxCount;
|
||||
}
|
||||
|
||||
// Real connected players only — NPCs have no socket and are excluded.
|
||||
public get connectedPlayerRttsMs(): Array<number> {
|
||||
return this._players.map((p) => p.rttMs);
|
||||
}
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
// Iterate the backing arrays directly: this runs per 200 Hz substep and the
|
||||
// `players` getter allocates a new array every call.
|
||||
this._players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
this._npcs.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public stepCommunication(deltaTimeInSeconds: number) {
|
||||
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
|
||||
public stepCommunication(
|
||||
deltaTimeInSeconds: number,
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
this._players.forEach((p) =>
|
||||
p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf),
|
||||
);
|
||||
}
|
||||
|
||||
public endGame(winner: CharacterTeam) {
|
||||
|
|
@ -120,12 +114,8 @@ export class PlayerContainer {
|
|||
}
|
||||
|
||||
public deletePlayer(player: Player) {
|
||||
const had = this._players.includes(player);
|
||||
if (this._players.includes(player)) {
|
||||
this._players = this._players.filter((p) => p !== player);
|
||||
// Only refill bots if this player was actually ours: a stale socket from a
|
||||
// previous round reports its disconnect against the new container, and
|
||||
// topping up on that would over-fill the roster.
|
||||
if (had) {
|
||||
this.createNPCs();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,44 +31,42 @@ import {
|
|||
isFiniteNumber,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io';
|
||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { BoundingBox } from '../physics/bounding-box';
|
||||
import { PhysicalContainer } from '../physics/physical-container';
|
||||
import { PlayerContainer } from './player-container';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { PlayerBase, Score } from './player-base';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
|
||||
const pingIntervalSeconds = 1;
|
||||
|
||||
const minimumAspectRatio = 0.2;
|
||||
const maximumAspectRatio = 8;
|
||||
|
||||
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
|
||||
const sheddableWhenBackedUp: ReadonlyArray<string> = [
|
||||
PropertyUpdatesForObjects.type,
|
||||
UpdateMinimap.type,
|
||||
InputAcknowledgement.type,
|
||||
];
|
||||
|
||||
export class Player extends PlayerBase {
|
||||
private aspectRatio: number = 16 / 9;
|
||||
public reconnectToken = '';
|
||||
public rttMs = 0;
|
||||
|
||||
private aspectRatio = 16 / 9;
|
||||
private timeSinceLastMessage = 0;
|
||||
// A Set: the per-snapshot diff is membership-heavy, and insertion-order
|
||||
// iteration keeps the streamed object order unchanged.
|
||||
private objectsInViewArea = new Set<GameObject>();
|
||||
private commandsToBeSent: Array<Command> = [];
|
||||
private winnerTeam?: CharacterTeam;
|
||||
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastInputReceiptMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
public reconnectToken = '';
|
||||
|
||||
// Server-side latency stat, reported in GameServer's periodic log.
|
||||
public rttMs = 0;
|
||||
private timeSinceLastPing = 0;
|
||||
private lastPingSentMs = 0;
|
||||
// Nonce of the ping awaiting a reply: prevents a client forging Pongs or
|
||||
// answering a stale ping to corrupt the measured RTT.
|
||||
private pendingPingNonce = 0;
|
||||
private nextPingNonce = 1;
|
||||
|
||||
// Input below is untrusted: every command arrives from `deserialize`
|
||||
// (a bare JSON.parse, which accepts Infinity as `1e999`). A single
|
||||
// non-finite value reaching the simulation is unrecoverable — it spreads
|
||||
// through the shared SDF and leaves a character that never dies or respawns.
|
||||
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
|
||||
this.aspectRatio = finiteInRange(
|
||||
|
|
@ -84,7 +82,7 @@ export class Player extends PlayerBase {
|
|||
return;
|
||||
}
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.handleMovementAction(new MoveActionCommand(direction, 0));
|
||||
this.character?.setMoveDirection(direction);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
|
||||
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
|
||||
|
|
@ -98,48 +96,34 @@ export class Player extends PlayerBase {
|
|||
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||
return;
|
||||
}
|
||||
// Record receipt whether or not leap() accepts it: the predictor must
|
||||
// stop replaying this leap either way.
|
||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.leap();
|
||||
},
|
||||
// Advances the acknowledgement at the client's send rate regardless of
|
||||
// whether input changed — otherwise a held key freezes the predictor's
|
||||
// replay window.
|
||||
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
|
||||
this.observeClientTime(c.clientTimeMs),
|
||||
};
|
||||
|
||||
// Tracks both the newest applied client-clock instant and the server-clock
|
||||
// instant it arrived. The predictor needs both: which inputs are already in
|
||||
// the snapshot, and how stale that answer was (see InputAcknowledgement).
|
||||
private observeClientTime(clientTimeMs: number) {
|
||||
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
|
||||
this.lastInputClientTimeMs = clientTimeMs;
|
||||
this.lastInputReceiptMs = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
playerInfo: PlayerInformation,
|
||||
playerContainer: PlayerContainer,
|
||||
objectContainer: PhysicalContainer,
|
||||
team: CharacterTeam,
|
||||
private readonly socket: Socket,
|
||||
score?: Score,
|
||||
) {
|
||||
super(playerInfo, playerContainer, objectContainer, team);
|
||||
super(playerInfo, playerContainer, objectContainer, team, score);
|
||||
this.createCharacter();
|
||||
this.step(0);
|
||||
|
||||
// Only one ping is ever in flight, so RTT is simply now − send-time.
|
||||
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
// Only the outstanding ping's nonce counts, once: a client cannot forge a
|
||||
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to
|
||||
// inflate the measurement is bounded by the clamp, and the next ping retires
|
||||
// the nonce, so any stall costs at most one ping interval.
|
||||
private observeClientTime(clientTimeMs: number) {
|
||||
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
|
||||
this.lastInputClientTimeMs = clientTimeMs;
|
||||
this.lastInputReceiptMs = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onPong = (nonce: unknown) => {
|
||||
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
|
||||
return;
|
||||
|
|
@ -151,86 +135,93 @@ export class Player extends PlayerBase {
|
|||
);
|
||||
};
|
||||
|
||||
// The Pong listener outlives the round: the socket survives a restart, so a
|
||||
// retired Player would stay reachable through it unless removed explicitly.
|
||||
public detachFromSocket() {
|
||||
this.socket.off(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
super.createCharacter();
|
||||
|
||||
this.objectsInViewArea.add(this.character!);
|
||||
this.queueCommandSend(new CreatePlayerCommand(this.character!));
|
||||
protected createCharacter(): CharacterPhysical {
|
||||
const character = super.createCharacter();
|
||||
this.objectsInViewArea.add(character);
|
||||
this.queueCommandSend(new CreatePlayerCommand(character));
|
||||
return character;
|
||||
}
|
||||
|
||||
private winnerTeam?: CharacterTeam;
|
||||
public onGameEnded(winnerTeam: CharacterTeam) {
|
||||
this.winnerTeam = winnerTeam;
|
||||
}
|
||||
|
||||
// Retained so an in-flight shot can still credit a kill to this corpse:
|
||||
// killCount is re-read at respawn, not snapshotted when the body died.
|
||||
private dyingCharacter?: CharacterPhysical | null;
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
this.stepLifecycle(deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
protected onCharacterDied(character: CharacterPhysical) {
|
||||
this.dyingCharacter = character;
|
||||
public queueCommandSend(command: Command) {
|
||||
this.commandsToBeSent.push(command);
|
||||
}
|
||||
|
||||
protected onBeforeRespawn() {
|
||||
if (this.dyingCharacter) {
|
||||
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
|
||||
this.dyingCharacter = null;
|
||||
public stepCommunications(
|
||||
deltaTime: number,
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
const remoteCalls: Array<RemoteCallsForObject> = [];
|
||||
for (const object of this.objectsInViewArea) {
|
||||
const calls = object.getRemoteCalls();
|
||||
if (calls.length > 0) {
|
||||
remoteCalls.push(new RemoteCallsForObject(object.id, calls));
|
||||
}
|
||||
}
|
||||
if (remoteCalls.length > 0) {
|
||||
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.pendingPingNonce = this.nextPingNonce++;
|
||||
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||
this.timeSinceLastMessage = 0;
|
||||
this.queueAnnouncement();
|
||||
this.queueSnapshot(propertyUpdatesOf);
|
||||
this.sendQueuedCommandsToClient();
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewAreaUpdate() {
|
||||
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||
const bb = new BoundingBox();
|
||||
bb.topLeft = viewArea.topLeft;
|
||||
bb.size = viewArea.size;
|
||||
|
||||
const inViewArea = new Set(
|
||||
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
|
||||
private queueSnapshot(
|
||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||
) {
|
||||
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||
const viewBox = new BoundingBox(
|
||||
topLeft[0],
|
||||
topLeft[0] + size[0],
|
||||
topLeft[1] - size[1],
|
||||
topLeft[1],
|
||||
);
|
||||
|
||||
// The owning character must always be in its own snapshot so the client
|
||||
// predictor never loses its authoritative anchor.
|
||||
const inViewArea = new Set(
|
||||
this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
|
||||
);
|
||||
if (this.character) {
|
||||
inViewArea.add(this.character);
|
||||
}
|
||||
|
||||
// Set membership rather than Array.includes: this diff runs per player per
|
||||
// snapshot over everything on screen and was quadratic in that.
|
||||
const newlyIntersecting = [...inViewArea].filter(
|
||||
(o) => !this.objectsInViewArea.has(o),
|
||||
);
|
||||
|
||||
const noLongerIntersecting = [...this.objectsInViewArea].filter(
|
||||
(o) => !inViewArea.has(o),
|
||||
);
|
||||
|
||||
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
|
||||
const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
|
||||
this.objectsInViewArea = inViewArea;
|
||||
|
||||
if (noLongerIntersecting.length > 0) {
|
||||
this.queueCommandSend(
|
||||
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
|
||||
);
|
||||
if (deleted.length > 0) {
|
||||
this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
|
||||
}
|
||||
|
||||
if (newlyIntersecting.length > 0) {
|
||||
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
|
||||
if (created.length > 0) {
|
||||
this.queueCommandSend(new CreateObjectsCommand(created));
|
||||
}
|
||||
|
||||
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
|
||||
|
||||
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
|
||||
for (const object of this.objectsInViewArea) {
|
||||
const update = object.getPropertyUpdatesForFrame();
|
||||
const update = propertyUpdatesOf(object);
|
||||
if (update) {
|
||||
propertyUpdates.push(update);
|
||||
}
|
||||
|
|
@ -239,8 +230,6 @@ export class Player extends PlayerBase {
|
|||
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
|
||||
);
|
||||
|
||||
// Tells the client how much of its own input is reflected in the snapshot
|
||||
// so its predictor can replay the rest. Only while alive.
|
||||
if (this.character) {
|
||||
this.queueCommandSend(
|
||||
new InputAcknowledgement(
|
||||
|
|
@ -255,8 +244,6 @@ export class Player extends PlayerBase {
|
|||
}
|
||||
}
|
||||
|
||||
// Every living player except this one, by absolute world position for the
|
||||
// minimap.
|
||||
private getMinimapPlayers(): Array<MinimapPlayer> {
|
||||
return this.playerContainer.players
|
||||
.filter((p) => p !== this && p.character?.isAlive)
|
||||
|
|
@ -266,42 +253,6 @@ export class Player extends PlayerBase {
|
|||
);
|
||||
}
|
||||
|
||||
private commandsToBeSent: Array<Command> = [];
|
||||
public queueCommandSend(command: Command) {
|
||||
this.commandsToBeSent.push(command);
|
||||
}
|
||||
|
||||
public stepCommunications(deltaTime: number) {
|
||||
// Test first and only build the command when there is something to send.
|
||||
let remoteCalls: Array<RemoteCallsForObject> | undefined;
|
||||
for (const object of this.objectsInViewArea) {
|
||||
const calls = object.getRemoteCalls();
|
||||
if (calls.length > 0) {
|
||||
(remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls));
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteCalls) {
|
||||
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.pendingPingNonce = this.nextPingNonce++;
|
||||
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||
this.handleAnnouncements();
|
||||
this.handleViewAreaUpdate();
|
||||
this.sendQueuedCommandsToClient();
|
||||
this.timeSinceLastMessage = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// engine.io's Socket exposes no bufferedAmount — it lives on the underlying
|
||||
// ws socket, and only the websocket transport has one.
|
||||
private get bufferedBytes(): number {
|
||||
const conn = this.socket.conn as
|
||||
| { transport?: { socket?: { bufferedAmount?: unknown } } }
|
||||
|
|
@ -311,53 +262,31 @@ export class Player extends PlayerBase {
|
|||
}
|
||||
|
||||
public sendQueuedCommandsToClient() {
|
||||
if (this.commandsToBeSent.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only state next tick regenerates in full may be dropped. Create/delete
|
||||
// are one-shot: handleViewAreaUpdate has ALREADY advanced
|
||||
// objectsPreviouslyInViewArea, so dropping them leaves this client
|
||||
// permanently missing objects and holding ghosts. They go out even backed up.
|
||||
if (this.bufferedBytes > maximumBufferedBytes) {
|
||||
// Matched by command type, not `instanceof`: a class identity from a
|
||||
// bundled shared module is not something to depend on.
|
||||
const sheddable: ReadonlyArray<string> = [
|
||||
PropertyUpdatesForObjects.type,
|
||||
UpdateMinimap.type,
|
||||
// Regenerated in full next tick — shed with the pose, never advance the
|
||||
// replay anchor past a snapshot the client never received.
|
||||
InputAcknowledgement.type,
|
||||
];
|
||||
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
|
||||
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
|
||||
this.commandsToBeSent = this.commandsToBeSent.filter(
|
||||
(c) => !sheddable.includes(c.type),
|
||||
(c) => !sheddableWhenBackedUp.includes(c.type),
|
||||
);
|
||||
}
|
||||
if (this.commandsToBeSent.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
||||
this.commandsToBeSent = [];
|
||||
}
|
||||
|
||||
public get scoreSnapshot(): { kills: number; deaths: number } {
|
||||
return {
|
||||
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
|
||||
deaths: this.sumDeaths,
|
||||
};
|
||||
}
|
||||
|
||||
private handleAnnouncements() {
|
||||
let announcement = '';
|
||||
private queueAnnouncement() {
|
||||
if (this.winnerTeam) {
|
||||
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
|
||||
this.queueCommandSend(
|
||||
new ServerAnnouncement(
|
||||
`Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`,
|
||||
),
|
||||
);
|
||||
} else if (!this.character) {
|
||||
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
|
||||
}
|
||||
|
||||
if (announcement) {
|
||||
this.queueCommandSend(new ServerAnnouncement(announcement));
|
||||
this.queueCommandSend(
|
||||
new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ module.exports = (env, argv) => ({
|
|||
minimizer: [
|
||||
new TerserJSPlugin({
|
||||
exclude: /node_modules/,
|
||||
// The custom serialization protocol keys on class names, so they must
|
||||
// survive minification (see shared/src/serialization).
|
||||
// The serialization protocol keys on class names.
|
||||
terserOptions: {
|
||||
keep_classnames: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json.
|
||||
const tseslint = require('typescript-eslint');
|
||||
const prettierRecommended = require('eslint-plugin-prettier/recommended');
|
||||
const unusedImports = require('eslint-plugin-unused-imports');
|
||||
|
|
@ -25,7 +24,6 @@ module.exports = tseslint.config(
|
|||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
// Renamed from the former no-var-requires; keep require() allowed.
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { glMatrix } from 'gl-matrix';
|
||||
import {
|
||||
applyArrayPlugins,
|
||||
LampBase,
|
||||
overrideDeserialization,
|
||||
PlanetBase,
|
||||
|
|
@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png';
|
|||
import '../static/favicons/favicon-16x16.png';
|
||||
import '../static/favicons/favicon-32x32.png';
|
||||
import '../static/favicons/favicon.ico';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
import { LandingPageBackground } from './scripts/landing-page-background';
|
||||
import { JoinFormHandler } from './scripts/join-form-handler';
|
||||
import { handleFullScreen } from './scripts/helper/handle-full-screen';
|
||||
import { Game } from './scripts/game';
|
||||
import ResizeObserver from 'resize-observer-polyfill';
|
||||
import { OptionsHandler } from './scripts/options-handler';
|
||||
const hide = (e: HTMLElement, d?: boolean) => {
|
||||
if (d) e.style.display = 'none';
|
||||
else e.style.visibility = 'hidden';
|
||||
};
|
||||
const show = (e: HTMLElement, d?: boolean, v?: string) => {
|
||||
if (d) e.style.display = v!;
|
||||
else e.style.visibility = 'inherit';
|
||||
};
|
||||
import { initializeOptions } from './scripts/options-handler';
|
||||
import { SoundHandler, Sounds } from './scripts/sound-handler';
|
||||
import { VibrationHandler } from './scripts/vibration-handler';
|
||||
import { CharacterView } from './scripts/objects/types/character-view';
|
||||
|
|
@ -36,36 +27,43 @@ import { PlanetView } from './scripts/objects/types/planet-view';
|
|||
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
applyArrayPlugins();
|
||||
|
||||
overrideDeserialization(CharacterBase, CharacterView);
|
||||
overrideDeserialization(PlanetBase, PlanetView);
|
||||
overrideDeserialization(LampBase, LampView);
|
||||
overrideDeserialization(ProjectileBase, ProjectileView);
|
||||
|
||||
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
|
||||
const nameInput = document.querySelector('#name') as HTMLInputElement;
|
||||
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
|
||||
const serverContainer = document.querySelector('#server-container') as HTMLElement;
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const overlay = document.querySelector('#overlay') as HTMLElement;
|
||||
const settings = document.querySelector('#settings') as HTMLElement;
|
||||
const toggleSettingsButton = document.querySelector(
|
||||
'#toggle-settings-container',
|
||||
) as HTMLElement;
|
||||
const minimize = document.querySelector('#minimize') as HTMLElement;
|
||||
const maximize = document.querySelector('#maximize') as HTMLElement;
|
||||
const logoutButton = document.querySelector('#logout') as HTMLElement;
|
||||
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
|
||||
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
||||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
||||
const query = <T extends HTMLElement>(selector: string) =>
|
||||
document.querySelector(selector) as T;
|
||||
|
||||
const landingUI = query('#landing-ui');
|
||||
const nameInput = query<HTMLInputElement>('#name');
|
||||
const joinGameForm = query<HTMLFormElement>('#join-game-form');
|
||||
const serverContainer = query('#server-container');
|
||||
const canvas = query<HTMLCanvasElement>('canvas');
|
||||
const overlay = query('#overlay');
|
||||
const settings = query('#settings');
|
||||
const toggleSettingsButton = query('#toggle-settings-container');
|
||||
const minimize = query('#minimize');
|
||||
const maximize = query('#maximize');
|
||||
const logoutButton = query('#logout');
|
||||
const enableSounds = query<HTMLInputElement>('#enable-sounds');
|
||||
const enableMusic = query<HTMLInputElement>('#enable-music');
|
||||
const enableVibration = query<HTMLInputElement>('#enable-vibration');
|
||||
const spinner = query('#spinner-container');
|
||||
|
||||
const joinNotice = document.createElement('p');
|
||||
joinNotice.className = 'join-notice';
|
||||
joinNotice.style.display = 'none';
|
||||
joinGameForm.prepend(joinNotice);
|
||||
|
||||
const setVisible = (e: HTMLElement, visible: boolean) => {
|
||||
e.style.visibility = visible ? 'inherit' : 'hidden';
|
||||
};
|
||||
const setDisplayed = (e: HTMLElement, display: string | null) => {
|
||||
e.style.display = display ?? 'none';
|
||||
};
|
||||
|
||||
const toggleSettings = () => {
|
||||
settings.className = settings.className === 'open' ? '' : 'open';
|
||||
SoundHandler.play(Sounds.click);
|
||||
|
|
@ -76,21 +74,19 @@ const applyServerContainerShadows = () => {
|
|||
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
|
||||
|
||||
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
|
||||
if (scrollHeight > clientHeight) {
|
||||
if (scrollTop <= 0) {
|
||||
if (scrollHeight <= clientHeight) {
|
||||
serverContainer.style.boxShadow = '';
|
||||
} else if (scrollTop <= 0) {
|
||||
serverContainer.style.boxShadow = topShadow;
|
||||
} else if (scrollTop + clientHeight >= scrollHeight) {
|
||||
serverContainer.style.boxShadow = bottomShadow;
|
||||
} else {
|
||||
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
|
||||
}
|
||||
} else {
|
||||
serverContainer.style.boxShadow = '';
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
let game: Game;
|
||||
let game: Game | undefined;
|
||||
|
||||
const storedUserName = localStorage.getItem('userName');
|
||||
if (storedUserName) {
|
||||
|
|
@ -113,7 +109,7 @@ const main = async () => {
|
|||
document.addEventListener('click', firstClickListener);
|
||||
|
||||
if (!VibrationHandler.isVibrationEnabledHeuristics) {
|
||||
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
|
||||
setDisplayed(query("label[for='enable-vibration']"), null);
|
||||
}
|
||||
|
||||
handleFullScreen(minimize, maximize);
|
||||
|
|
@ -122,28 +118,28 @@ const main = async () => {
|
|||
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
|
||||
serverContainer.addEventListener('scroll', applyServerContainerShadows);
|
||||
|
||||
OptionsHandler.initialize({
|
||||
initializeOptions({
|
||||
soundsEnabled: enableSounds,
|
||||
vibrationEnabled: enableVibration,
|
||||
musicEnabled: enableMusic,
|
||||
});
|
||||
|
||||
logoutButton.addEventListener('click', () => {
|
||||
game.destroy();
|
||||
game?.destroy();
|
||||
toggleSettings();
|
||||
});
|
||||
window.onpopstate = () => game.destroy();
|
||||
window.onpopstate = () => game?.destroy();
|
||||
|
||||
for (;;) {
|
||||
show(spinner);
|
||||
hide(logoutButton, true);
|
||||
show(landingUI, true, 'flex');
|
||||
setVisible(spinner, true);
|
||||
setDisplayed(logoutButton, null);
|
||||
setDisplayed(landingUI, 'flex');
|
||||
|
||||
const background = new LandingPageBackground(canvas);
|
||||
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
|
||||
|
||||
await background.renderer;
|
||||
hide(spinner);
|
||||
setVisible(spinner, false);
|
||||
|
||||
const playerDecision = await joinHandler.getPlayerDecision();
|
||||
|
||||
|
|
@ -153,29 +149,25 @@ const main = async () => {
|
|||
history.pushState(true, '');
|
||||
}
|
||||
|
||||
hide(landingUI, true);
|
||||
show(spinner);
|
||||
setDisplayed(landingUI, null);
|
||||
setVisible(spinner, true);
|
||||
background.destroy();
|
||||
game = new Game(playerDecision, canvas, overlay);
|
||||
const gameOver = game.start();
|
||||
await game.started;
|
||||
hide(spinner);
|
||||
show(logoutButton, true, 'block');
|
||||
setVisible(spinner, false);
|
||||
setDisplayed(logoutButton, 'block');
|
||||
await gameOver;
|
||||
|
||||
const reason = game.lastRejectionReason;
|
||||
if (reason) {
|
||||
joinNotice.innerText = Game.rejectionText(reason);
|
||||
joinNotice.style.display = 'block';
|
||||
} else {
|
||||
joinNotice.style.display = 'none';
|
||||
}
|
||||
const reason = game.rejectionReason;
|
||||
joinNotice.innerText = reason ? Game.rejectionText(reason) : '';
|
||||
setDisplayed(joinNotice, reason ? 'block' : null);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
hide(spinner);
|
||||
setVisible(spinner, false);
|
||||
joinNotice.innerText = 'Something went wrong. Please reload the page.';
|
||||
joinNotice.style.display = 'block';
|
||||
setDisplayed(joinNotice, 'block');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { holdDurationToCharge } from 'shared';
|
||||
import { chargeHeldSince } from 'shared';
|
||||
import { pointer } from './helper/pointer';
|
||||
|
||||
let element: HTMLElement | undefined;
|
||||
|
|
@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) {
|
|||
function endChargeIndicator() {
|
||||
if (element) {
|
||||
cancelAnimationFrame(raf);
|
||||
element.parentElement?.removeChild(element);
|
||||
element.remove();
|
||||
element = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ function updateChargeIndicator() {
|
|||
return;
|
||||
}
|
||||
|
||||
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000);
|
||||
const charge = chargeHeldSince(heldSince);
|
||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
||||
charge * 360
|
||||
|
|
@ -44,15 +44,14 @@ function updateChargeIndicator() {
|
|||
if (isFollowingPointer) {
|
||||
const cursor = pointer.displayPosition;
|
||||
if (cursor) {
|
||||
element.style.left = `${cursor.x}px`;
|
||||
element.style.top = `${cursor.y}px`;
|
||||
element.style.left = `${cursor[0]}px`;
|
||||
element.style.top = `${cursor[1]}px`;
|
||||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(updateChargeIndicator);
|
||||
}
|
||||
|
||||
// The one public surface; the functions above stay module-private.
|
||||
export const ChargeIndicator = {
|
||||
begin: beginChargeIndicator,
|
||||
end: endChargeIndicator,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue