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 { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
|
||||||
import { LampPhysical } from './objects/lamp-physical';
|
import { LampPhysical } from './objects/lamp-physical';
|
||||||
import { PlanetPhysical } from './objects/planet-physical';
|
import { PlanetPhysical } from './objects/planet-physical';
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
import { PhysicalContainer } from './physics/physical-container';
|
||||||
import { Physical } from './physics/physicals/physical';
|
|
||||||
|
|
||||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
const objects: Array<Physical> = [];
|
const planets: Array<PlanetPhysical> = [];
|
||||||
const lights: Array<Physical> = [];
|
const lamps: Array<LampPhysical> = [];
|
||||||
|
|
||||||
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
||||||
const circumference = 2 * Math.PI * r;
|
const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength;
|
||||||
const stepCount = circumference * settings.objectsOnCircleLength;
|
|
||||||
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
||||||
const position = vec2.rotate(
|
const position = vec2.rotate(
|
||||||
vec2.create(),
|
vec2.create(),
|
||||||
|
|
@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
rad,
|
rad,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (objects.length !== 0 && Random.getRandom() > 0.5) {
|
if (planets.length !== 0 && Random.getRandom() > 0.5) {
|
||||||
if (
|
if (
|
||||||
evaluateSdf(position, objects) > 200 &&
|
evaluateSdf(position, planets) > 200 &&
|
||||||
!lights.find((l) => l.distance(position) < 2500)
|
!lamps.find((l) => l.distance(position) < 2500)
|
||||||
) {
|
) {
|
||||||
lights.push(
|
lamps.push(
|
||||||
new LampPhysical(
|
new LampPhysical(
|
||||||
position,
|
position,
|
||||||
hsl(
|
hsl(
|
||||||
|
|
@ -37,56 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (
|
||||||
if (
|
evaluateSdf(position, planets) > 1400 &&
|
||||||
evaluateSdf(position, objects) > 1400 &&
|
!lamps.find((l) => l.distance(position) < 1700)
|
||||||
!lights.find((l) => l.distance(position) < 1700)
|
|
||||||
) {
|
) {
|
||||||
const planet =
|
const isKeystone = planets.length === 0;
|
||||||
objects.length === 0
|
const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone
|
||||||
? new PlanetPhysical(
|
? [1600, 2400, 80, 300]
|
||||||
|
: [300, 1600, 20, 100];
|
||||||
|
planets.push(
|
||||||
|
new PlanetPhysical(
|
||||||
PlanetBase.createPlanetVertices(
|
PlanetBase.createPlanetVertices(
|
||||||
position,
|
position,
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(sizeMin, sizeMax),
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(sizeMin, sizeMax),
|
||||||
Random.getRandomInRange(80, 300),
|
Random.getRandomInRange(roughMin, roughMax),
|
||||||
),
|
),
|
||||||
true,
|
isKeystone,
|
||||||
)
|
objectContainer.game,
|
||||||
: new PlanetPhysical(
|
|
||||||
PlanetBase.createPlanetVertices(
|
|
||||||
position,
|
|
||||||
Random.getRandomInRange(300, 1600),
|
|
||||||
Random.getRandomInRange(300, 1600),
|
|
||||||
Random.getRandomInRange(20, 100),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`);
|
||||||
|
|
||||||
objects.push(planet);
|
for (const lamp of lamps) {
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) => {
|
|
||||||
let nearest: PlanetPhysical | undefined;
|
let nearest: PlanetPhysical | undefined;
|
||||||
let nearestDistance = Infinity;
|
let nearestDistance = Infinity;
|
||||||
planets.forEach((planet) => {
|
for (const planet of planets) {
|
||||||
const distance = planet.distance(lamp.center);
|
const distance = planet.distance(lamp.center);
|
||||||
if (distance < nearestDistance) {
|
if (distance < nearestDistance) {
|
||||||
nearestDistance = distance;
|
nearestDistance = distance;
|
||||||
nearest = planet;
|
nearest = planet;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
nearest?.addLamp(lamp);
|
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 { Server, Socket } from 'socket.io';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { performance } from 'node:perf_hooks';
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
@ -13,39 +12,33 @@ import {
|
||||||
GameEndCommand,
|
GameEndCommand,
|
||||||
GameStartCommand,
|
GameStartCommand,
|
||||||
Command,
|
Command,
|
||||||
CommandReceiver,
|
|
||||||
CommandExecutors,
|
|
||||||
ServerAnnouncement,
|
ServerAnnouncement,
|
||||||
JoinRejectionReason,
|
JoinRejectionReason,
|
||||||
beginPropertyUpdateGeneration,
|
GameObject,
|
||||||
|
PropertyUpdatesForObject,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
|
import { PhysicalContainer } from './physics/physical-container';
|
||||||
import { createWorld } from './create-world';
|
import { createWorld } from './create-world';
|
||||||
import { Options } from './options';
|
import { Options } from './options';
|
||||||
|
import { GameEvents } from './game-events';
|
||||||
import { CarriedScore, PlayerContainer } from './players/player-container';
|
import { CarriedScore, PlayerContainer } from './players/player-container';
|
||||||
import { ServerFullError } from './players/server-full-error';
|
|
||||||
import { Player } from './players/player';
|
import { Player } from './players/player';
|
||||||
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
|
||||||
|
|
||||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
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 => {
|
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { name, reconnectToken } = payload as Record<string, unknown>;
|
const { name, reconnectToken } = payload as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
|
|
||||||
name: name as string,
|
name: name as string,
|
||||||
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
|
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GameServer extends CommandReceiver {
|
export class GameServer implements GameEvents {
|
||||||
private objects!: PhysicalContainer;
|
private objects!: PhysicalContainer;
|
||||||
private players!: PlayerContainer;
|
private players!: PlayerContainer;
|
||||||
private lastPhysicsBase = process.hrtime.bigint();
|
private lastPhysicsBase = process.hrtime.bigint();
|
||||||
|
|
@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver {
|
||||||
private isInEndGame = false;
|
private isInEndGame = false;
|
||||||
private timeScaling = 1;
|
private timeScaling = 1;
|
||||||
private statReportMs = Date.now();
|
private statReportMs = Date.now();
|
||||||
|
private timeSinceLastServerStateUpdate = 0;
|
||||||
|
private timeSinceLastPointUpdate = 0;
|
||||||
|
private physicsAccumulator = 0;
|
||||||
|
private saturatedFrames = 0;
|
||||||
|
private droppedInboundMessages = 0;
|
||||||
|
|
||||||
private initialize() {
|
private readonly joinedSockets = new Map<
|
||||||
const previousPlayers = this.players;
|
Socket,
|
||||||
this.releaseJoinedSockets();
|
{ player: Player; release: () => void }
|
||||||
this.objects = new PhysicalContainer();
|
>();
|
||||||
createWorld(this.objects);
|
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
|
||||||
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)),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly io: Server,
|
private readonly io: Server,
|
||||||
private options: Options,
|
private options: Options,
|
||||||
) {
|
) {
|
||||||
super();
|
|
||||||
|
|
||||||
this.initialize();
|
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) => {
|
io.on('connection', (socket: Socket) => {
|
||||||
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
|
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
|
||||||
if (!this.allowInboundMessage(socket)) {
|
if (!this.allowInboundMessage(socket)) {
|
||||||
|
|
@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver {
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||||
if (!this.allowInboundMessage(socket)) {
|
if (this.allowInboundMessage(socket)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.join(gameStateSubscribedRoom);
|
socket.join(gameStateSubscribedRoom);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly joinedSockets = new Map<
|
private initialize() {
|
||||||
Socket,
|
const previousPlayers = this.players;
|
||||||
{ player: Player; release: () => void }
|
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) {
|
private handleJoin(socket: Socket, payload: unknown) {
|
||||||
const playerInfo = asPlayerInformation(payload);
|
const playerInfo = asPlayerInformation(payload);
|
||||||
|
|
@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver {
|
||||||
return;
|
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);
|
const carried = this.retireGhost(playerInfo.reconnectToken);
|
||||||
|
|
||||||
if (this.players.isFull) {
|
if (this.players.isFull) {
|
||||||
|
|
@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let player: Player;
|
const player = this.players.createPlayer(playerInfo, socket, carried);
|
||||||
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 onPlayerToServer = (json: string) => {
|
const onPlayerToServer = (json: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver {
|
||||||
this.sendServerStateUpdate();
|
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 {
|
private allowInboundMessage(socket: Socket): boolean {
|
||||||
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP
|
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
|
||||||
// correction, a VM resync) would make the refill negative and lock the
|
|
||||||
// client out of sending input for as long as the step was.
|
|
||||||
const nowMs = performance.now();
|
const nowMs = performance.now();
|
||||||
const burst = settings.maxInboundMessageBurst;
|
const burst = settings.maxInboundMessageBurst;
|
||||||
const perSecond = settings.maxInboundMessagesPerSecond;
|
const perSecond = settings.maxInboundMessagesPerSecond;
|
||||||
|
|
@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
for (const [socket, { player }] of this.joinedSockets) {
|
for (const [socket, { player }] of this.joinedSockets) {
|
||||||
if (player.reconnectToken === token) {
|
if (player.reconnectToken === token) {
|
||||||
const carried = { team: player.team, ...player.scoreSnapshot };
|
const carried = { team: player.team, ...player.score };
|
||||||
this.removeJoined(socket);
|
this.removeJoined(socket);
|
||||||
return carried;
|
return carried;
|
||||||
}
|
}
|
||||||
|
|
@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
this.joinedSockets.delete(socket);
|
this.joinedSockets.delete(socket);
|
||||||
this.inboundBudget.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.release();
|
||||||
joined.player.destroy();
|
joined.player.destroy();
|
||||||
this.players.deletePlayer(joined.player);
|
this.players.deletePlayer(joined.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseJoinedSockets() {
|
|
||||||
this.joinedSockets.forEach(({ release }) => release());
|
|
||||||
this.joinedSockets.clear();
|
|
||||||
this.inboundBudget = new WeakMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
private timeSinceLastServerStateUpdate = 0;
|
|
||||||
public sendServerStateUpdate() {
|
public sendServerStateUpdate() {
|
||||||
this.io
|
this.io
|
||||||
.to(gameStateSubscribedRoom)
|
.to(gameStateSubscribedRoom)
|
||||||
.emit(TransportEvents.ServerInfoUpdate, [
|
.emit(TransportEvents.ServerInfoUpdate, [
|
||||||
this.players.count,
|
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();
|
this.handlePhysics();
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPoints({ blue, red }: GeneratePointsCommand) {
|
public addPoints(blue: number, red: number) {
|
||||||
if (this.isInEndGame) {
|
if (this.isInEndGame) {
|
||||||
return;
|
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) {
|
private announceMatchPointOnce(team: CharacterTeam, points: number) {
|
||||||
if (
|
if (
|
||||||
!this.matchPointAnnounced[team] &&
|
!this.matchPointAnnounced[team] &&
|
||||||
points >= this.options.scoreLimit * settings.matchPointScoreRatio
|
points >= this.options.scoreLimit * settings.matchPointScoreRatio
|
||||||
) {
|
) {
|
||||||
this.matchPointAnnounced[team] = true;
|
this.matchPointAnnounced[team] = true;
|
||||||
this.players.queueCommandForEachClient(
|
this.announce(`Match point — team <span class="${team}">${team}</span>!`);
|
||||||
new ServerAnnouncement(
|
|
||||||
`Match point — team <span class="${team}">${team}</span>!`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private endGame(winningTeam: CharacterTeam) {
|
private endGame(winningTeam: CharacterTeam) {
|
||||||
this.isInEndGame = true;
|
this.isInEndGame = true;
|
||||||
const endTitleLength = 6;
|
|
||||||
this.players.endGame(winningTeam);
|
this.players.endGame(winningTeam);
|
||||||
this.players.queueCommandForEachClient(new GameEndCommand());
|
this.players.queueCommandForEachClient(new GameEndCommand());
|
||||||
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
|
setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private timeSinceLastPointUpdate = 0;
|
private reportStats() {
|
||||||
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();
|
|
||||||
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
||||||
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
|
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) {
|
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||||
this.timeSinceLastServerStateUpdate = 0;
|
this.timeSinceLastServerStateUpdate = 0;
|
||||||
this.sendServerStateUpdate();
|
this.sendServerStateUpdate();
|
||||||
|
|
@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver {
|
||||||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
|
||||||
scaledDelta /= this.timeScaling;
|
scaledDelta /= this.timeScaling;
|
||||||
}
|
}
|
||||||
this.objects.handleCommand(new StepCommand(scaledDelta, this));
|
this.objects.step(scaledDelta);
|
||||||
this.players.step(scaledDelta);
|
this.players.step(scaledDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
beginPropertyUpdateGeneration();
|
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
|
||||||
this.players.stepCommunication(delta);
|
this.players.stepCommunication(delta, (object) => {
|
||||||
|
if (!propertyUpdates.has(object)) {
|
||||||
|
propertyUpdates.set(object, object.getPropertyUpdates());
|
||||||
|
}
|
||||||
|
return propertyUpdates.get(object);
|
||||||
|
});
|
||||||
this.objects.resetRemoteCalls();
|
this.objects.resetRemoteCalls();
|
||||||
|
|
||||||
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
|
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
|
||||||
|
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
|
||||||
setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get serverInfo(): ServerInformation {
|
public get serverInfo(): ServerInformation {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared';
|
import { Random, serverInformationEndpoint, settings } from 'shared';
|
||||||
import minimist from 'minimist';
|
import minimist from 'minimist';
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import { GameServer } from './game-server';
|
import { GameServer } from './game-server';
|
||||||
|
|
@ -10,7 +10,6 @@ import { defaultOptions } from './options';
|
||||||
import parser from 'socket.io-msgpack-parser';
|
import parser from 'socket.io-msgpack-parser';
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
applyArrayPlugins();
|
|
||||||
|
|
||||||
const optionOverrides = minimist(process.argv.slice(2));
|
const optionOverrides = minimist(process.argv.slice(2));
|
||||||
const options = {
|
const options = {
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
settings,
|
settings,
|
||||||
MoveActionCommand,
|
|
||||||
serializesTo,
|
|
||||||
Circle,
|
Circle,
|
||||||
CharacterBase,
|
CharacterBase,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
CommandExecutors,
|
|
||||||
CommandReceiver,
|
|
||||||
CharacterMovementSnapshot,
|
CharacterMovementSnapshot,
|
||||||
|
GameObject,
|
||||||
mix,
|
mix,
|
||||||
clamp01,
|
clamp01,
|
||||||
strengthToCharge,
|
strengthToCharge,
|
||||||
|
|
@ -20,6 +17,7 @@ import {
|
||||||
decayMomentum,
|
decayMomentum,
|
||||||
tickPlanetDetachment,
|
tickPlanetDetachment,
|
||||||
characterCenter,
|
characterCenter,
|
||||||
|
sumGravity,
|
||||||
CharacterWorld,
|
CharacterWorld,
|
||||||
GroundSurface,
|
GroundSurface,
|
||||||
headRadius,
|
headRadius,
|
||||||
|
|
@ -29,69 +27,45 @@ import {
|
||||||
rightFootOffset,
|
rightFootOffset,
|
||||||
boundRadius,
|
boundRadius,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { Physical } from '../physics/physical';
|
||||||
import { CirclePhysical } from './circle-physical';
|
import { CirclePhysical } from './circle-physical';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
import { ProjectilePhysical } from './projectile-physical';
|
import { ProjectilePhysical } from './projectile-physical';
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { PlanetPhysical, planetsIn } from './planet-physical';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
|
||||||
import { PlanetPhysical } from './planet-physical';
|
|
||||||
import {
|
|
||||||
StepCommand,
|
|
||||||
ReactToCollisionCommand,
|
|
||||||
GeneratePointsCommand,
|
|
||||||
} from '../commands/commands';
|
|
||||||
|
|
||||||
interface BodyPose {
|
const placeholder = new Circle(vec2.create(), 0);
|
||||||
head: Circle;
|
|
||||||
leftFoot: Circle;
|
|
||||||
rightFoot: Circle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@serializesTo(CharacterBase)
|
export class CharacterPhysical extends CharacterBase implements Physical {
|
||||||
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
|
|
||||||
public readonly canCollide = true;
|
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 head: CirclePhysical;
|
||||||
public leftFoot: CirclePhysical;
|
public leftFoot: CirclePhysical;
|
||||||
public rightFoot: CirclePhysical;
|
public rightFoot: CirclePhysical;
|
||||||
|
|
||||||
private movementActions: Array<MoveActionCommand> = [];
|
public direction = 0;
|
||||||
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
|
public currentPlanet: GroundSurface | undefined;
|
||||||
|
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||||
|
public readonly bodyVelocity = vec2.create();
|
||||||
|
|
||||||
private headVelocity = new Circle(vec2.create(), 0);
|
private projectileStrength = settings.playerMaxStrength;
|
||||||
private leftFootVelocity = new Circle(vec2.create(), 0);
|
private timeAlive = 0;
|
||||||
private rightFootVelocity = new Circle(vec2.create(), 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 = {
|
private movementDirections: Array<vec2> = [];
|
||||||
[StepCommand.type]: this.step.bind(this),
|
private lastMovementDirection = vec2.create();
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
|
||||||
};
|
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(
|
constructor(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
private readonly container: PhysicalContainer,
|
private readonly container: PhysicalContainer,
|
||||||
startPosition: vec2,
|
startPosition: vec2,
|
||||||
) {
|
) {
|
||||||
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
|
super(
|
||||||
this.head = new CirclePhysical(
|
id(),
|
||||||
vec2.add(vec2.create(), startPosition, headOffset),
|
name,
|
||||||
headRadius,
|
killCount,
|
||||||
|
deathCount,
|
||||||
|
team,
|
||||||
|
settings.playerMaxHealth,
|
||||||
|
placeholder,
|
||||||
|
placeholder,
|
||||||
|
placeholder,
|
||||||
|
);
|
||||||
|
const part = (offset: vec2, radius: number) =>
|
||||||
|
new CirclePhysical(
|
||||||
|
vec2.add(vec2.create(), startPosition, offset),
|
||||||
|
radius,
|
||||||
this,
|
this,
|
||||||
container,
|
container,
|
||||||
);
|
);
|
||||||
this.leftFoot = new CirclePhysical(
|
this.head = part(headOffset, headRadius);
|
||||||
vec2.add(vec2.create(), startPosition, leftFootOffset),
|
this.leftFoot = part(leftFootOffset, feetRadius);
|
||||||
feetRadius,
|
this.rightFoot = part(rightFootOffset, 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly movementWorld: CharacterWorld = {
|
private readonly movementWorld: CharacterWorld = {
|
||||||
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
|
|
||||||
groundsNear: (center, radius) =>
|
groundsNear: (center, radius) =>
|
||||||
this.container
|
planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
|
||||||
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
|
||||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
|
|
||||||
stepBody: (body, deltaTimeInSeconds) => {
|
stepBody: (body, deltaTimeInSeconds) => {
|
||||||
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
||||||
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
|
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 {
|
private get isSpawnProtected(): boolean {
|
||||||
return (
|
return (
|
||||||
!this.hasFiredSinceSpawn &&
|
!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 {
|
public get isAlive(): boolean {
|
||||||
return !this.isDestroyed;
|
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 {
|
public get groundPlanet(): PlanetPhysical | undefined {
|
||||||
return this.currentPlanet as 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 {
|
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||||
return new CharacterMovementSnapshot(
|
return new CharacterMovementSnapshot(
|
||||||
this.direction,
|
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) {
|
public addKill(victimName: string, charge = 0) {
|
||||||
this.killCount++;
|
this.killCount++;
|
||||||
this.killStreak++;
|
this.killStreak++;
|
||||||
|
|
@ -214,15 +194,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCollision({ other }: ReactToCollisionCommand) {
|
public onCollision(other: GameObject) {
|
||||||
if (
|
if (
|
||||||
// A corpse keeps its collidable circles for the despawn animation; the
|
!this.isAlive ||
|
||||||
// isAlive guard stops a flying corpse from eating shots aimed past it.
|
!(other instanceof ProjectilePhysical) ||
|
||||||
this.isAlive &&
|
other.team === this.team ||
|
||||||
other instanceof ProjectilePhysical &&
|
!other.isAlive
|
||||||
other.team !== this.team &&
|
|
||||||
other.isAlive
|
|
||||||
) {
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
other.destroy();
|
other.destroy();
|
||||||
|
|
||||||
if (this.isSpawnProtected) {
|
if (this.isSpawnProtected) {
|
||||||
|
|
@ -234,7 +215,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.lastSyncedHealth = Math.round(this.health);
|
this.lastSyncedHealth = Math.round(this.health);
|
||||||
this.remoteCall('setHealth', this.health);
|
this.remoteCall('setHealth', this.health);
|
||||||
|
|
||||||
if (this.health <= 0 && this.isAlive) {
|
if (this.health <= 0) {
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
|
|
@ -247,7 +228,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
other.originator.registerHit(other.charge);
|
other.originator.registerHit(other.charge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public shootTowards(position: vec2, charge = 0) {
|
public shootTowards(position: vec2, charge = 0) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.timeSinceLastShot = 0;
|
this.timeSinceLastShot = 0;
|
||||||
this.hasFiredSinceSpawn = true;
|
this.hasFiredSinceSpawn = true;
|
||||||
|
|
||||||
const requestedCharge = clamp01(charge);
|
|
||||||
const desiredStrength = mix(
|
const desiredStrength = mix(
|
||||||
settings.chargeShotStrengthMin,
|
settings.chargeShotStrengthMin,
|
||||||
settings.chargeShotStrengthMax,
|
settings.chargeShotStrengthMax,
|
||||||
requestedCharge,
|
clamp01(charge),
|
||||||
);
|
);
|
||||||
const strength = Math.min(desiredStrength, this.projectileStrength);
|
const strength = Math.min(desiredStrength, this.projectileStrength);
|
||||||
this.projectileStrength -= strength;
|
this.projectileStrength -= strength;
|
||||||
|
|
||||||
const c = strengthToCharge(strength);
|
const c = strengthToCharge(strength);
|
||||||
|
|
||||||
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
||||||
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
||||||
|
|
||||||
vec2.normalize(direction, direction);
|
vec2.normalize(direction, direction);
|
||||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
this.container.addObject(
|
||||||
const shotDirection = vec2.clone(direction);
|
new ProjectilePhysical(
|
||||||
const velocity = vec2.scale(direction, direction, speed);
|
|
||||||
const projectile = new ProjectilePhysical(
|
|
||||||
vec2.clone(this.center),
|
vec2.clone(this.center),
|
||||||
radius,
|
radius,
|
||||||
strength,
|
strength,
|
||||||
this.team,
|
this.team,
|
||||||
velocity,
|
vec2.scale(vec2.create(), direction, speed),
|
||||||
this,
|
this,
|
||||||
this.container,
|
this.container,
|
||||||
c,
|
c,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
this.container.addObject(projectile);
|
|
||||||
|
|
||||||
if (c > 0) {
|
if (c > 0) {
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
shotDirection,
|
direction,
|
||||||
-settings.chargeShotRecoilMax * c,
|
-settings.chargeShotRecoilMax * c,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
this.timeSinceLastLeap = 0;
|
this.timeSinceLastLeap = 0;
|
||||||
this.projectileStrength -= settings.leapStrengthCost;
|
this.projectileStrength -= settings.leapStrengthCost;
|
||||||
|
applyLeapImpulse(this, this.lastMovementDirection);
|
||||||
// Same impulse the client predicts with (shared), so a leap launches
|
|
||||||
// identically on both sides.
|
|
||||||
applyLeapImpulse(this, this.lastMovementAction.direction);
|
|
||||||
this.remoteCall('onLeap');
|
this.remoteCall('onLeap');
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public onDie(killedInCombat = false) {
|
||||||
return getBoundingBoxOfCircle(new Circle(this.center, boundRadius));
|
if (this.isDestroyed) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.isDestroyed = true;
|
||||||
|
this.remoteCall('onDie');
|
||||||
|
|
||||||
public get gameObject(): this {
|
if (killedInCombat) {
|
||||||
return this;
|
const points = settings.playerKillPoint;
|
||||||
}
|
this.container.game.addPoints(
|
||||||
|
this.team === CharacterTeam.blue ? 0 : points,
|
||||||
public get center(): vec2 {
|
this.team === CharacterTeam.red ? 0 : points,
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||||
|
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
new UpdatePropertyCommand('head', this.head, this.headVelocity),
|
new UpdatePropertyCommand('head', this.head, headVelocity),
|
||||||
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
|
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
|
||||||
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
|
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
|
||||||
new UpdatePropertyCommand(
|
new UpdatePropertyCommand(
|
||||||
'strength',
|
'strength',
|
||||||
this.projectileStrength,
|
this.projectileStrength,
|
||||||
|
|
@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `previous` is consumed as scratch so this allocates nothing beyond the
|
public step(deltaTimeInSeconds: number) {
|
||||||
// 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);
|
|
||||||
this.timeAlive += deltaTimeInSeconds;
|
this.timeAlive += deltaTimeInSeconds;
|
||||||
this.timeSinceLastLeap += 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.isDestroyed) {
|
||||||
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||||
this.destroy();
|
this.container.removeObject(this);
|
||||||
} else {
|
} else {
|
||||||
this.freeFallCorpse(deltaTimeInSeconds);
|
this.freeFallCorpse(deltaTimeInSeconds);
|
||||||
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
|
} else if (this.hasJustBorn) {
|
||||||
return;
|
if (this.timeAlive > settings.spawnDespawnTime) {
|
||||||
}
|
|
||||||
|
|
||||||
if (this.hasJustBorn) {
|
|
||||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
|
||||||
this.hasJustBorn = false;
|
this.hasJustBorn = false;
|
||||||
this.animateScaling(1);
|
this.animateScaling(1);
|
||||||
} else {
|
} else {
|
||||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
|
} else {
|
||||||
return;
|
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);
|
tickPlanetDetachment(this, deltaTimeInSeconds);
|
||||||
|
|
||||||
this.timeSinceLastShot += deltaTimeInSeconds;
|
this.timeSinceLastShot += deltaTimeInSeconds;
|
||||||
|
|
||||||
this.projectileStrength = Math.min(
|
this.projectileStrength = Math.min(
|
||||||
settings.playerMaxStrength,
|
settings.playerMaxStrength,
|
||||||
this.projectileStrength +
|
this.projectileStrength +
|
||||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.regenerateHealth(deltaTimeInSeconds);
|
|
||||||
|
|
||||||
this.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;
|
this.timeSinceLastDamage += deltaTimeInSeconds;
|
||||||
if (
|
if (
|
||||||
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
||||||
|
|
@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
);
|
);
|
||||||
this.syncHealth();
|
this.syncHealth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.groundPlanet?.registerPresence(this);
|
||||||
|
|
||||||
|
stepCharacterMovement(
|
||||||
|
this,
|
||||||
|
this.movementWorld,
|
||||||
|
this.averageAndResetMovementDirections(),
|
||||||
|
deltaTimeInSeconds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDie(killedInCombat = false) {
|
private averageAndResetMovementDirections(): vec2 {
|
||||||
this.wasKilledInCombat = killedInCombat;
|
const direction = vec2.create();
|
||||||
this.isDestroyed = true;
|
if (this.movementDirections.length === 0) {
|
||||||
this.remoteCall('onDie');
|
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() {
|
private animateScaling(q: number) {
|
||||||
this.container.removeObject(this);
|
this.head.radius = headRadius * q;
|
||||||
this.container.removeObject(this.head);
|
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
||||||
this.container.removeObject(this.leftFoot);
|
}
|
||||||
this.container.removeObject(this.rightFoot);
|
|
||||||
|
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 { vec2 } from 'gl-matrix';
|
||||||
import {
|
import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared';
|
||||||
applyForce,
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
Circle,
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
CommandExecutors,
|
import { Physical } from '../physics/physical';
|
||||||
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;
|
|
||||||
|
|
||||||
|
export class CirclePhysical extends Circle implements Physical {
|
||||||
|
public readonly canCollide = true;
|
||||||
public velocity = vec2.create();
|
public velocity = vec2.create();
|
||||||
public lastNormal = vec2.fromValues(0, 1);
|
public lastNormal = vec2.fromValues(0, 1);
|
||||||
|
|
||||||
private _boundingBox: BoundingBox;
|
private readonly box = new BoundingBox();
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _center: vec2,
|
center: vec2,
|
||||||
private _radius: number,
|
radius: number,
|
||||||
public owner: GameObject,
|
public readonly owner: Physical,
|
||||||
private readonly container: PhysicalContainer,
|
private readonly container: PhysicalContainer,
|
||||||
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
|
|
||||||
// interface the movement simulation operates on.
|
|
||||||
public readonly restitution = 0,
|
public readonly restitution = 0,
|
||||||
) {
|
) {
|
||||||
super();
|
super(center, radius);
|
||||||
this._boundingBox = new BoundingBox();
|
|
||||||
this.syncBoundingBox();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public get boundingBox(): BoundingBox {
|
||||||
return this._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;
|
||||||
public get center(): vec2 {
|
this.box.yMax = this.center[1] + this.radius;
|
||||||
return this._center;
|
return this.box;
|
||||||
}
|
|
||||||
|
|
||||||
public set center(value: vec2) {
|
|
||||||
this._center = value;
|
|
||||||
this.syncBoundingBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
public onCollision(c: ReactToCollisionCommand) {
|
|
||||||
this.owner.handleCommand(c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get gameObject(): GameObject {
|
public get gameObject(): GameObject {
|
||||||
return this.owner;
|
return this.owner.gameObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get radius(): number {
|
public onCollision(other: GameObject) {
|
||||||
return this._radius;
|
this.owner.onCollision?.(other);
|
||||||
}
|
|
||||||
|
|
||||||
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 applyForce(force: vec2, timeInSeconds: number) {
|
public applyForce(force: vec2, timeInSeconds: number) {
|
||||||
applyForce(this, force, timeInSeconds);
|
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(
|
public stepManually(
|
||||||
deltaTimeInSeconds: number,
|
deltaTimeInSeconds: number,
|
||||||
possibleIntersectors?: Array<Physical>,
|
possibleIntersectors?: Array<Physical>,
|
||||||
): {
|
): GameObject | undefined {
|
||||||
hitObject: GameObject | undefined;
|
|
||||||
velocity: vec2;
|
|
||||||
} {
|
|
||||||
const intersecting = (
|
const intersecting = (
|
||||||
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
|
||||||
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
|
||||||
|
|
||||||
const { hitObject, velocity } = resolveCircleMovement(
|
const hit = resolveCircleMovement(
|
||||||
this,
|
this,
|
||||||
deltaTimeInSeconds,
|
deltaTimeInSeconds,
|
||||||
intersecting,
|
intersecting,
|
||||||
(intersected) => {
|
(intersected) => {
|
||||||
const physical = intersected as Physical;
|
const physical = intersected as Physical;
|
||||||
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
|
physical.onCollision?.(this.gameObject);
|
||||||
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
|
this.onCollision(physical.gameObject);
|
||||||
},
|
},
|
||||||
);
|
) as Physical | undefined;
|
||||||
|
|
||||||
this.syncBoundingBox();
|
return hit?.gameObject;
|
||||||
|
|
||||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||||
const sweep = vec2.length(
|
const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
|
||||||
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.
|
|
||||||
return this.container.findIntersecting(
|
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 { 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';
|
export class LampPhysical extends LampBase implements Physical {
|
||||||
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 {
|
|
||||||
public readonly canCollide = false;
|
public readonly canCollide = false;
|
||||||
public readonly canMove = false;
|
public readonly boundingBox: BoundingBox;
|
||||||
|
|
||||||
constructor(center: vec2, color: vec3, lightness: number) {
|
constructor(center: vec2, color: vec3, lightness: number) {
|
||||||
super(id(), center, color, lightness);
|
super(id(), center, color, lightness);
|
||||||
}
|
this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance);
|
||||||
|
|
||||||
private _boundingBox?: ImmutableBoundingBox;
|
|
||||||
|
|
||||||
public get boundingBox(): ImmutableBoundingBox {
|
|
||||||
if (!this._boundingBox) {
|
|
||||||
this._boundingBox = getBoundingBoxOfCircle(
|
|
||||||
new Circle(this.center, settings.lightCutoffDistance),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._boundingBox;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get gameObject(): this {
|
public get gameObject(): this {
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,90 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Circle,
|
|
||||||
clamp01,
|
clamp01,
|
||||||
id,
|
id,
|
||||||
mix,
|
mix,
|
||||||
Random,
|
Random,
|
||||||
serializesTo,
|
|
||||||
settings,
|
settings,
|
||||||
PlanetBase,
|
PlanetBase,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
planetDistance,
|
|
||||||
planetGravity,
|
|
||||||
CommandExecutors,
|
|
||||||
CommandReceiver,
|
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import {
|
import { GameEvents } from '../game-events';
|
||||||
GeneratePointsCommand,
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
AnnounceCommand,
|
import { Physical } from '../physics/physical';
|
||||||
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 { LampPhysical } from './lamp-physical';
|
import { LampPhysical } from './lamp-physical';
|
||||||
import type { CharacterPhysical } from './character-physical';
|
import type { CharacterPhysical } from './character-physical';
|
||||||
|
|
||||||
@serializesTo(PlanetBase)
|
export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
|
||||||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||||
public readonly canCollide = true;
|
|
||||||
public readonly canMove = false;
|
|
||||||
// Marks this as standable ground for the shared movement simulation.
|
|
||||||
public readonly isGround = true;
|
|
||||||
|
|
||||||
|
export class PlanetPhysical extends PlanetBase implements Physical {
|
||||||
|
public readonly boundingBox: BoundingBox;
|
||||||
public readonly sizePointMultiplier: number;
|
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 readonly lamps: Array<LampPhysical> = [];
|
||||||
|
|
||||||
private lastTeam: CharacterTeam = CharacterTeam.neutral;
|
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 presentCharacters: Array<CharacterPhysical> = [];
|
||||||
private isContested = false;
|
private isContested = false;
|
||||||
|
private timeSinceLastPointGeneration = 0;
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
constructor(
|
||||||
[StepCommand.type]: this.step.bind(this),
|
vertices: Array<vec2>,
|
||||||
};
|
isKeystone: boolean,
|
||||||
|
private readonly game: GameEvents,
|
||||||
public addLamp(lamp: LampPhysical) {
|
) {
|
||||||
this.lamps.push(lamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(vertices: Array<vec2>, isKeystone = false) {
|
|
||||||
super(id(), vertices, 0.5, isKeystone);
|
super(id(), vertices, 0.5, isKeystone);
|
||||||
|
|
||||||
const sizeClass = clamp01(
|
const sizeClass = clamp01(
|
||||||
(this.radius - settings.planetMinReferenceRadius) /
|
(this.radius - settings.planetMinReferenceRadius) /
|
||||||
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
|
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
|
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
|
||||||
|
this.angularVelocity =
|
||||||
this.rotationSpeed =
|
|
||||||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
(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) {
|
public registerPresence(character: CharacterPhysical) {
|
||||||
this.presentCharacters.push(character);
|
this.presentCharacters.push(character);
|
||||||
}
|
}
|
||||||
|
|
||||||
public distance(target: vec2): number {
|
public step(deltaTimeInSeconds: number) {
|
||||||
// The shared planet outline — the same function the client predictor
|
this.advanceRotation(deltaTimeInSeconds);
|
||||||
// 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;
|
|
||||||
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
||||||
|
|
||||||
// In reverse order, so that teams can achieve a 100% control.
|
this.generatePoints();
|
||||||
this.getPoints(game);
|
|
||||||
this.resolveCapture(deltaTimeInSeconds);
|
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) {
|
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;
|
const currentTeam = this.team;
|
||||||
if (currentTeam === this.lastTeam) {
|
if (currentTeam === this.lastTeam) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
this.lastTeam = currentTeam;
|
this.lastTeam = currentTeam;
|
||||||
|
|
||||||
if (currentTeam !== CharacterTeam.neutral) {
|
if (currentTeam !== CharacterTeam.neutral) {
|
||||||
const reward = Math.round(
|
this.remoteCall(
|
||||||
settings.captureFlipPointReward * this.sizePointMultiplier,
|
'generatedPoints',
|
||||||
);
|
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.awardToOwner(settings.captureFlipPointReward);
|
||||||
|
|
||||||
if (this.isKeystone) {
|
if (this.isKeystone) {
|
||||||
game.handleCommand(
|
this.game.announce(
|
||||||
new AnnounceCommand(
|
|
||||||
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
|
`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 control = Math.abs(this.ownership - 0.5) / 0.5;
|
||||||
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
|
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
|
||||||
const color = settings.palette[settings.colorIndices[currentTeam]];
|
const color = settings.palette[settings.colorIndices[currentTeam]];
|
||||||
|
|
||||||
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
|
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
|
||||||
|
|
||||||
this.remoteCall('onFlipped', currentTeam);
|
this.remoteCall('onFlipped', currentTeam);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
private takeControl(team: CharacterTeam, deltaTime: number) {
|
||||||
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) {
|
|
||||||
if (team === CharacterTeam.blue) {
|
if (team === CharacterTeam.blue) {
|
||||||
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||||
} else if (team === CharacterTeam.red) {
|
} else if (team === CharacterTeam.red) {
|
||||||
|
|
@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
-Math.sign(this.ownership - 0.5) *
|
-Math.sign(this.ownership - 0.5) *
|
||||||
(0.5 / settings.loseControlTimeInSeconds) *
|
(0.5 / settings.loseControlTimeInSeconds) *
|
||||||
deltaTime;
|
deltaTime;
|
||||||
if (
|
if ((previous - 0.5) * (this.ownership - 0.5) < 0) {
|
||||||
(previous < 0.5 && this.ownership > 0.5) ||
|
|
||||||
(previous > 0.5 && this.ownership < 0.5)
|
|
||||||
) {
|
|
||||||
this.ownership = 0.5;
|
this.ownership = 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
this.ownership = clamp01(this.ownership);
|
this.ownership = clamp01(this.ownership);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): ImmutableBoundingBox {
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||||
if (!this._boundingBox) {
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
// The polygon spins about its centre (see distance), so this static box
|
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
||||||
// has to cover every orientation, not just the spawn-time one: take the
|
new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity),
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,55 +2,42 @@ import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
id,
|
id,
|
||||||
settings,
|
settings,
|
||||||
serializesTo,
|
|
||||||
ProjectileBase,
|
ProjectileBase,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
PropertyUpdatesForObject,
|
PropertyUpdatesForObject,
|
||||||
UpdatePropertyCommand,
|
UpdatePropertyCommand,
|
||||||
CommandExecutors,
|
GameObject,
|
||||||
Circle,
|
evaluateSdf,
|
||||||
marchCircle,
|
sumGravity,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
import { CirclePhysical } from './circle-physical';
|
import { CirclePhysical } from './circle-physical';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { Physical } from '../physics/physical';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { CharacterPhysical } from './character-physical';
|
import { CharacterPhysical } from './character-physical';
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { planetsIn } from './planet-physical';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
|
||||||
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
|
|
||||||
|
|
||||||
@serializesTo(ProjectileBase)
|
export class ProjectilePhysical extends ProjectileBase implements Physical {
|
||||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
|
||||||
public readonly canCollide = true;
|
public readonly canCollide = true;
|
||||||
public readonly canMove = true;
|
public readonly object: CirclePhysical;
|
||||||
|
|
||||||
private isDestroyed = false;
|
private isDestroyed = false;
|
||||||
private bounceCount = 0;
|
private bounceCount = 0;
|
||||||
|
|
||||||
public object: CirclePhysical;
|
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
|
||||||
[StepCommand.type]: this.handleStep.bind(this),
|
|
||||||
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
center: vec2,
|
center: vec2,
|
||||||
radius: number,
|
radius: number,
|
||||||
public strength: number,
|
public strength: number,
|
||||||
team: CharacterTeam,
|
team: CharacterTeam,
|
||||||
private velocity: vec2,
|
velocity: vec2,
|
||||||
public readonly originator: CharacterPhysical,
|
public readonly originator: CharacterPhysical,
|
||||||
readonly container: PhysicalContainer,
|
private 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.
|
|
||||||
public readonly charge: number = 0,
|
public readonly charge: number = 0,
|
||||||
) {
|
) {
|
||||||
super(id(), center, radius, team, strength);
|
super(id(), center, radius, team, strength);
|
||||||
this.object = new CirclePhysical(center, radius, this, container, 0.9);
|
this.object = new CirclePhysical(this.center, radius, this, container, 0.9);
|
||||||
|
vec2.copy(this.object.velocity, velocity);
|
||||||
this.moveOutsideOfObject();
|
this.moveOutsideFriendlyBodies();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isAlive(): boolean {
|
public get isAlive(): boolean {
|
||||||
|
|
@ -58,35 +45,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
}
|
}
|
||||||
|
|
||||||
public get direction(): vec2 {
|
public get direction(): vec2 {
|
||||||
const direction = vec2.clone(this.velocity);
|
const direction = vec2.clone(this.object.velocity);
|
||||||
return vec2.length(direction) > 0
|
return vec2.length(direction) > 0
|
||||||
? vec2.normalize(direction, direction)
|
? vec2.normalize(direction, direction)
|
||||||
: vec2.fromValues(0, -1);
|
: vec2.fromValues(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveOutsideOfObject() {
|
public get boundingBox(): BoundingBox {
|
||||||
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 {
|
|
||||||
return this.object.boundingBox;
|
return this.object.boundingBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
return this.object.distance(target);
|
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() {
|
public destroy() {
|
||||||
if (!this.isDestroyed) {
|
if (!this.isDestroyed) {
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
|
|
@ -105,7 +83,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCollision({ other }: ReactToCollisionCommand) {
|
public onCollision(other: GameObject) {
|
||||||
if (
|
if (
|
||||||
!(other instanceof CharacterPhysical && other.team === this.team) &&
|
!(other instanceof CharacterPhysical && other.team === this.team) &&
|
||||||
this.bounceCount++ === settings.projectileMaxBounceCount
|
this.bounceCount++ === settings.projectileMaxBounceCount
|
||||||
|
|
@ -116,15 +94,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
|
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
new UpdatePropertyCommand('center', this.center, this.velocity),
|
new UpdatePropertyCommand('center', this.center, this.object.velocity),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
public step(deltaTimeInSeconds: number) {
|
||||||
this.advance(deltaTimeInSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private advance(deltaTimeInSeconds: number) {
|
|
||||||
super.step(deltaTimeInSeconds);
|
super.step(deltaTimeInSeconds);
|
||||||
|
|
||||||
if (this.strength <= 0) {
|
if (this.strength <= 0) {
|
||||||
|
|
@ -132,24 +106,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
return;
|
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(
|
const intersecting = this.container.findIntersecting(
|
||||||
getBoundingBoxOfCircle(
|
BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance),
|
||||||
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
this.velocity,
|
this.object.velocity,
|
||||||
this.velocity,
|
this.object.velocity,
|
||||||
forceAtPosition(this.center, intersecting),
|
sumGravity(planetsIn(intersecting), this.center),
|
||||||
settings.projectileGravityScale * deltaTimeInSeconds,
|
settings.projectileGravityScale * deltaTimeInSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
vec2.copy(this.object.velocity, this.velocity);
|
this.object.stepManually(deltaTimeInSeconds, intersecting);
|
||||||
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
|
|
||||||
vec2.copy(this.velocity, velocity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,28 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { BoundingBoxBase } from './bounding-box-base';
|
|
||||||
|
|
||||||
export class BoundingBox extends BoundingBoxBase {
|
export class BoundingBox {
|
||||||
public get xMin(): number {
|
constructor(
|
||||||
return this._xMin;
|
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) {
|
public intersects(other: BoundingBox): boolean {
|
||||||
this._xMin = value;
|
return (
|
||||||
}
|
this.xMin < other.xMax &&
|
||||||
|
this.xMax > other.xMin &&
|
||||||
public set xMax(value: number) {
|
this.yMin < other.yMax &&
|
||||||
this._xMax = value;
|
this.yMax > other.yMin
|
||||||
}
|
);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,29 @@
|
||||||
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
|
import { GameEvents } from '../game-events';
|
||||||
import { BoundingBoxList } from './bounding-box-list';
|
import { BoundingBox } from './bounding-box';
|
||||||
import { BoundingBoxTree } from './bounding-box-tree';
|
import { Physical } from './physical';
|
||||||
import { Physical } from '../physicals/physical';
|
|
||||||
import { StaticPhysical } from '../physicals/static-physical';
|
|
||||||
import { DynamicPhysical } from '../physicals/dynamic-physical';
|
|
||||||
import { Command, CommandReceiver } from 'shared';
|
|
||||||
|
|
||||||
export class PhysicalContainer extends CommandReceiver {
|
export class PhysicalContainer {
|
||||||
private isTreeInitialized = false;
|
|
||||||
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
|
|
||||||
private staticBoundingBoxes = new BoundingBoxTree();
|
|
||||||
private dynamicBoundingBoxes = new BoundingBoxList();
|
|
||||||
private objects: Array<Physical> = [];
|
private objects: Array<Physical> = [];
|
||||||
|
|
||||||
protected defaultCommandExecutor(c: Command) {
|
constructor(public readonly game: GameEvents) {}
|
||||||
this.objects.forEach((o) => o.handleCommand(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
public initialize() {
|
|
||||||
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
|
|
||||||
this.isTreeInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public addObject(physical: Physical) {
|
public addObject(physical: Physical) {
|
||||||
this.objects.push(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) {
|
public removeObject(physical: Physical) {
|
||||||
this.objects = this.objects.filter((p) => p !== object);
|
this.objects = this.objects.filter((o) => o !== physical);
|
||||||
this.dynamicBoundingBoxes.remove(object);
|
}
|
||||||
|
|
||||||
|
public step(deltaTimeInSeconds: number) {
|
||||||
|
this.objects.forEach((o) => o.step?.(deltaTimeInSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetRemoteCalls() {
|
public resetRemoteCalls() {
|
||||||
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
|
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
|
||||||
}
|
}
|
||||||
|
|
||||||
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
|
public findIntersecting(box: BoundingBox): Array<Physical> {
|
||||||
return [
|
return this.objects.filter((o) => o.boundingBox.intersects(box));
|
||||||
...this.staticBoundingBoxes.findIntersecting(box),
|
|
||||||
...this.dynamicBoundingBoxes.findIntersecting(box),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
import { DynamicPhysical } from './dynamic-physical';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { StaticPhysical } from './static-physical';
|
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 { vec2 } from 'gl-matrix';
|
||||||
import {
|
import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared';
|
||||||
PlayerInformation,
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
settings,
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
Circle,
|
|
||||||
Random,
|
|
||||||
MoveActionCommand,
|
|
||||||
CharacterTeam,
|
|
||||||
Id,
|
|
||||||
} from 'shared';
|
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
import { PlayerBase } from './player-base';
|
import { PlayerBase } from './player-base';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
|
||||||
import { CharacterPhysical } from '../objects/character-physical';
|
import { CharacterPhysical } from '../objects/character-physical';
|
||||||
import { PlanetPhysical } from '../objects/planet-physical';
|
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
|
||||||
import { ProjectilePhysical } from '../objects/projectile-physical';
|
import { ProjectilePhysical } from '../objects/projectile-physical';
|
||||||
import { Physical } from '../physics/physicals/physical';
|
import { Physical } from '../physics/physical';
|
||||||
|
|
||||||
const npcTuning = {
|
const tuning = {
|
||||||
planIntervalSeconds: 1,
|
planIntervalSeconds: 1,
|
||||||
shootIntervalSeconds: 1.5,
|
shootIntervalSeconds: 1.5,
|
||||||
reactionIntervalSeconds: 1 / 10,
|
reactionIntervalSeconds: 1 / 10,
|
||||||
|
|
@ -40,7 +32,6 @@ const npcTuning = {
|
||||||
|
|
||||||
dodgeThreatRange: 450,
|
dodgeThreatRange: 450,
|
||||||
dodgeApproachDot: 0.6,
|
dodgeApproachDot: 0.6,
|
||||||
|
|
||||||
dodgeBaseChance: 0.25,
|
dodgeBaseChance: 0.25,
|
||||||
dodgeAggressionChance: 0.35,
|
dodgeAggressionChance: 0.35,
|
||||||
dodgeCommitSeconds: 0.35,
|
dodgeCommitSeconds: 0.35,
|
||||||
|
|
@ -64,27 +55,33 @@ const npcTuning = {
|
||||||
leapChancePerSecond: 0.35,
|
leapChancePerSecond: 0.35,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface Enemy {
|
||||||
|
character: CharacterPhysical;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class NPC extends PlayerBase {
|
export class NPC extends PlayerBase {
|
||||||
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
|
||||||
private timeSinceLastPlan = 10000;
|
private movement = vec2.create();
|
||||||
private timeSinceLastShoot = 10000;
|
private timeSinceLastPlan = Infinity;
|
||||||
private isWandering = false;
|
private timeSinceLastShoot = Infinity;
|
||||||
|
private timeSinceObserve = Infinity;
|
||||||
private timeSinceLastWanderingConsideration = 0;
|
private timeSinceLastWanderingConsideration = 0;
|
||||||
|
private isWandering = false;
|
||||||
private isComingBack = false;
|
private isComingBack = false;
|
||||||
|
|
||||||
private readonly aggression = Random.getRandomInRange(
|
private readonly aggression = Random.getRandomInRange(
|
||||||
npcTuning.aggressionMin,
|
tuning.aggressionMin,
|
||||||
npcTuning.aggressionMax,
|
tuning.aggressionMax,
|
||||||
);
|
);
|
||||||
|
|
||||||
private aimTargetId: Id = null;
|
private aimTargetId: Id | null = null;
|
||||||
private readonly aimTargetLastPosition = vec2.create();
|
private readonly aimTargetLastPosition = vec2.create();
|
||||||
|
|
||||||
private readonly dodgeDirection = vec2.create();
|
private readonly dodgeDirection = vec2.create();
|
||||||
private dodgeCommitRemaining = 0;
|
private dodgeCommitRemaining = 0;
|
||||||
private dodgeCooldownRemaining = 0;
|
private dodgeCooldownRemaining = 0;
|
||||||
|
|
||||||
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
|
|
||||||
private nearObjects: Array<Physical> = [];
|
private nearObjects: Array<Physical> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -95,7 +92,6 @@ export class NPC extends PlayerBase {
|
||||||
) {
|
) {
|
||||||
super(playerInfo, playerContainer, objectContainer, team);
|
super(playerInfo, playerContainer, objectContainer, team);
|
||||||
this.createCharacter();
|
this.createCharacter();
|
||||||
this.step(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public step(deltaTimeInSeconds: number) {
|
public step(deltaTimeInSeconds: number) {
|
||||||
|
|
@ -106,44 +102,40 @@ export class NPC extends PlayerBase {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
|
||||||
npcTuning.wanderReconsiderSeconds
|
tuning.wanderReconsiderSeconds
|
||||||
) {
|
) {
|
||||||
this.timeSinceLastWanderingConsideration = 0;
|
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.timeSinceLastPlan = 0;
|
||||||
this.plan();
|
this.plan();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
|
||||||
) {
|
|
||||||
this.timeSinceObserve = 0;
|
|
||||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
this.dodgeCommitRemaining -= deltaTimeInSeconds;
|
||||||
this.dodgeCooldownRemaining -= 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 (
|
if (
|
||||||
!this.isComingBack &&
|
!this.isComingBack &&
|
||||||
character.groundPlanet &&
|
character.groundPlanet &&
|
||||||
vec2.length(movement) > 0 &&
|
vec2.length(this.movement) > 0 &&
|
||||||
Random.getRandom() <
|
Random.getRandom() <
|
||||||
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
|
||||||
) {
|
) {
|
||||||
character.leap();
|
character.leap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
|
||||||
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
|
|
||||||
) {
|
|
||||||
this.timeSinceLastShoot = 0;
|
this.timeSinceLastShoot = 0;
|
||||||
this.tryShoot(this.nearObjects);
|
this.tryShoot(character, this.nearObjects);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,20 +151,18 @@ export class NPC extends PlayerBase {
|
||||||
}
|
}
|
||||||
this.isComingBack = false;
|
this.isComingBack = false;
|
||||||
|
|
||||||
const nearObjects = this.observe(npcTuning.planScanRadius);
|
const nearObjects = this.observe(tuning.planScanRadius);
|
||||||
const enemies = this.enemiesByDistance(nearObjects);
|
const nearest = this.enemiesByDistance(nearObjects)[0];
|
||||||
|
|
||||||
const nearest = enemies[0];
|
|
||||||
if (nearest) {
|
if (nearest) {
|
||||||
const fleeRange =
|
const fleeRange =
|
||||||
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
|
tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression);
|
||||||
if (nearest.distance < fleeRange) {
|
if (nearest.distance < fleeRange) {
|
||||||
vec2.subtract(this.direction, this.center, nearest.character.center);
|
vec2.subtract(this.direction, this.center, nearest.character.center);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chaseRange =
|
const chaseRange =
|
||||||
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
|
tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
|
||||||
if (nearest.distance < chaseRange) {
|
if (nearest.distance < chaseRange) {
|
||||||
vec2.subtract(this.direction, nearest.character.center, this.center);
|
vec2.subtract(this.direction, nearest.character.center, this.center);
|
||||||
return;
|
return;
|
||||||
|
|
@ -182,7 +172,7 @@ export class NPC extends PlayerBase {
|
||||||
if (!this.isWandering) {
|
if (!this.isWandering) {
|
||||||
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
|
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
|
||||||
if (planet) {
|
if (planet) {
|
||||||
vec2.subtract(this.direction, planet.planet.center, this.center);
|
vec2.subtract(this.direction, planet.center, this.center);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,11 +181,14 @@ export class NPC extends PlayerBase {
|
||||||
this.direction,
|
this.direction,
|
||||||
this.direction,
|
this.direction,
|
||||||
vec2.create(),
|
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) {
|
if (this.dodgeCommitRemaining > 0) {
|
||||||
return vec2.clone(this.dodgeDirection);
|
return vec2.clone(this.dodgeDirection);
|
||||||
}
|
}
|
||||||
|
|
@ -204,23 +197,20 @@ export class NPC extends PlayerBase {
|
||||||
if (dodge) {
|
if (dodge) {
|
||||||
if (
|
if (
|
||||||
Random.getRandom() <
|
Random.getRandom() <
|
||||||
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
|
tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
|
||||||
) {
|
) {
|
||||||
vec2.copy(this.dodgeDirection, dodge);
|
vec2.copy(this.dodgeDirection, dodge);
|
||||||
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
|
this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
|
||||||
return vec2.clone(this.dodgeDirection);
|
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) {
|
if (planet && planet.team !== this.team) {
|
||||||
const enemies = this.enemiesByDistance(nearObjects);
|
const enemies = this.enemiesByDistance(nearObjects);
|
||||||
if (
|
if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
|
||||||
enemies.length === 0 ||
|
|
||||||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
|
|
||||||
) {
|
|
||||||
return vec2.create();
|
return vec2.create();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -239,12 +229,12 @@ export class NPC extends PlayerBase {
|
||||||
}
|
}
|
||||||
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
|
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
|
||||||
const distance = vec2.length(toMe);
|
const distance = vec2.length(toMe);
|
||||||
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
|
if (distance > tuning.dodgeThreatRange || distance === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
vec2.normalize(toMe, toMe);
|
vec2.normalize(toMe, toMe);
|
||||||
if (
|
if (
|
||||||
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
|
vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
|
||||||
distance < threatDistance
|
distance < threatDistance
|
||||||
) {
|
) {
|
||||||
threatDistance = distance;
|
threatDistance = distance;
|
||||||
|
|
@ -261,14 +251,11 @@ export class NPC extends PlayerBase {
|
||||||
if (vec2.dot(perpendicular, toMe) < 0) {
|
if (vec2.dot(perpendicular, toMe) < 0) {
|
||||||
vec2.negate(perpendicular, perpendicular);
|
vec2.negate(perpendicular, perpendicular);
|
||||||
}
|
}
|
||||||
vec2.normalize(perpendicular, perpendicular);
|
return vec2.normalize(perpendicular, perpendicular);
|
||||||
|
|
||||||
return perpendicular;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private tryShoot(nearObjects: Array<Physical>) {
|
private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
|
||||||
const enemies = this.enemiesByDistance(nearObjects);
|
const visible = this.enemiesByDistance(nearObjects).find((e) =>
|
||||||
const visible = enemies.find((e) =>
|
|
||||||
this.hasLineOfSightTo(e.character.center, nearObjects),
|
this.hasLineOfSightTo(e.character.center, nearObjects),
|
||||||
);
|
);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
|
@ -276,71 +263,69 @@ export class NPC extends PlayerBase {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = visible.character;
|
const { character: target, distance } = visible;
|
||||||
const distance = visible.distance;
|
|
||||||
|
|
||||||
const velocity = vec2.create();
|
const velocity = vec2.create();
|
||||||
if (this.aimTargetId === target.id) {
|
if (this.aimTargetId === target.id) {
|
||||||
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
|
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;
|
this.aimTargetId = target.id;
|
||||||
vec2.copy(this.aimTargetLastPosition, target.center);
|
vec2.copy(this.aimTargetLastPosition, target.center);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Random.getRandom() >
|
Random.getRandom() >
|
||||||
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
|
tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const charge =
|
const charge =
|
||||||
distance > npcTuning.chargeRangeThreshold &&
|
distance > tuning.chargeRangeThreshold &&
|
||||||
Random.getRandom() <
|
Random.getRandom() <
|
||||||
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
|
tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
|
||||||
? Random.getRandomInRange(npcTuning.chargeMin, 1)
|
? Random.getRandomInRange(tuning.chargeMin, 1)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const projectileSpeed =
|
const projectileSpeed =
|
||||||
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
|
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
|
||||||
const leadTime = distance / projectileSpeed;
|
const aim = vec2.scaleAndAdd(
|
||||||
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
|
vec2.create(),
|
||||||
|
target.center,
|
||||||
|
velocity,
|
||||||
|
distance / projectileSpeed,
|
||||||
|
);
|
||||||
|
|
||||||
const spread =
|
const spread =
|
||||||
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
|
(tuning.spreadBase + distance * tuning.spreadPerDistance) *
|
||||||
(npcTuning.spreadAggressionFalloff - this.aggression);
|
(tuning.spreadAggressionFalloff - this.aggression);
|
||||||
aim.x += Random.getRandomInRange(-spread, spread);
|
aim[0] += Random.getRandomInRange(-spread, spread);
|
||||||
aim.y += 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 {
|
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
|
||||||
const planets: Array<PlanetPhysical> = [];
|
const planets = planetsIn(nearObjects);
|
||||||
for (const o of nearObjects) {
|
|
||||||
if (o.gameObject instanceof PlanetPhysical) {
|
|
||||||
planets.push(o.gameObject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (planets.length === 0) {
|
if (planets.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const direction = vec2.subtract(vec2.create(), target, this.center);
|
const direction = vec2.subtract(vec2.create(), target, this.center);
|
||||||
const totalDistance = vec2.length(direction);
|
const totalDistance = vec2.length(direction);
|
||||||
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
|
if (totalDistance <= tuning.lineOfSightStartOffset) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
vec2.normalize(direction, direction);
|
vec2.normalize(direction, direction);
|
||||||
|
|
||||||
let traveled = npcTuning.lineOfSightStartOffset;
|
let traveled = tuning.lineOfSightStartOffset;
|
||||||
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
|
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
|
||||||
while (traveled < totalDistance) {
|
while (traveled < totalDistance) {
|
||||||
let sdf = Infinity;
|
let sdf = Infinity;
|
||||||
for (const planet of planets) {
|
for (const planet of planets) {
|
||||||
sdf = Math.min(sdf, planet.distance(position));
|
sdf = Math.min(sdf, planet.distance(position));
|
||||||
}
|
}
|
||||||
if (sdf < npcTuning.lineOfSightClearance) {
|
if (sdf < tuning.lineOfSightClearance) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
traveled += sdf;
|
traveled += sdf;
|
||||||
|
|
@ -351,45 +336,34 @@ export class NPC extends PlayerBase {
|
||||||
|
|
||||||
private observe(radius: number): Array<Physical> {
|
private observe(radius: number): Array<Physical> {
|
||||||
return this.objectContainer.findIntersecting(
|
return this.objectContainer.findIntersecting(
|
||||||
getBoundingBoxOfCircle(new Circle(this.center, radius)),
|
BoundingBox.ofCircle(this.center, radius),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private enemiesByDistance(
|
private enemiesByDistance(nearObjects: Array<Physical>): Array<Enemy> {
|
||||||
nearObjects: Array<Physical>,
|
const enemies: Array<Enemy> = [];
|
||||||
): Array<{ character: CharacterPhysical; distance: number }> {
|
|
||||||
const seen = new Set<CharacterPhysical>();
|
|
||||||
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
|
|
||||||
for (const o of nearObjects) {
|
for (const o of nearObjects) {
|
||||||
const c = o.gameObject;
|
const c = o.gameObject;
|
||||||
if (
|
if (
|
||||||
c instanceof CharacterPhysical &&
|
c instanceof CharacterPhysical &&
|
||||||
c !== this.character &&
|
c !== this.character &&
|
||||||
c.isAlive &&
|
c.isAlive &&
|
||||||
c.team !== this.team &&
|
c.team !== this.team
|
||||||
!seen.has(c)
|
|
||||||
) {
|
) {
|
||||||
seen.add(c);
|
|
||||||
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
|
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enemies.sort((a, b) => a.distance - b.distance);
|
return enemies.sort((a, b) => a.distance - b.distance);
|
||||||
return enemies;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private capturablePlanetsByDistance(
|
private capturablePlanetsByDistance(
|
||||||
nearObjects: Array<Physical>,
|
nearObjects: Array<Physical>,
|
||||||
): Array<{ planet: PlanetPhysical; distance: number }> {
|
): Array<PlanetPhysical> {
|
||||||
const planets = nearObjects
|
return planetsIn(nearObjects)
|
||||||
.filter(
|
.filter((p) => p.team !== this.team)
|
||||||
(o): o is Physical =>
|
.sort(
|
||||||
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
|
(a, b) =>
|
||||||
)
|
vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center),
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
Circle,
|
|
||||||
PlayerInformation,
|
PlayerInformation,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
Random,
|
Random,
|
||||||
|
|
@ -10,53 +9,64 @@ import {
|
||||||
boundRadius,
|
boundRadius,
|
||||||
evaluateSdf,
|
evaluateSdf,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
import { CharacterPhysical } from '../objects/character-physical';
|
import { CharacterPhysical } from '../objects/character-physical';
|
||||||
import { PlanetPhysical } from '../objects/planet-physical';
|
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
|
|
||||||
const maximumNameLength = 40;
|
const maximumNameLength = 40;
|
||||||
|
|
||||||
|
export interface Score {
|
||||||
|
kills: number;
|
||||||
|
deaths: number;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class PlayerBase extends CommandReceiver {
|
export abstract class PlayerBase extends CommandReceiver {
|
||||||
public character?: CharacterPhysical | null;
|
public character: CharacterPhysical | null = null;
|
||||||
public center: vec2 = vec2.create();
|
public center: vec2 = vec2.create();
|
||||||
|
|
||||||
protected sumKills = 0;
|
protected lastCharacter?: CharacterPhysical;
|
||||||
protected sumDeaths = 0;
|
|
||||||
protected timeUntilRespawn = 0;
|
protected timeUntilRespawn = 0;
|
||||||
|
private kills: number;
|
||||||
|
private deaths: number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly playerInfo: PlayerInformation,
|
protected readonly playerInfo: PlayerInformation,
|
||||||
protected readonly playerContainer: PlayerContainer,
|
protected readonly playerContainer: PlayerContainer,
|
||||||
protected readonly objectContainer: PhysicalContainer,
|
protected readonly objectContainer: PhysicalContainer,
|
||||||
public readonly team: CharacterTeam,
|
public readonly team: CharacterTeam,
|
||||||
|
score: Score = { kills: 0, deaths: 0 },
|
||||||
) {
|
) {
|
||||||
super();
|
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(
|
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),
|
sanitizeName(this.playerInfo.name, maximumNameLength),
|
||||||
this.sumKills,
|
this.kills,
|
||||||
this.sumDeaths,
|
this.deaths,
|
||||||
this.team,
|
this.team,
|
||||||
this.objectContainer,
|
this.objectContainer,
|
||||||
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
|
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.objectContainer.addObject(this.character);
|
this.objectContainer.addObject(this.character);
|
||||||
|
this.center = this.character.center;
|
||||||
|
return this.character;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract step(deltaTimeInSeconds: number): void;
|
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 {
|
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
|
||||||
if (this.character) {
|
if (this.character) {
|
||||||
this.center = this.character.center;
|
this.center = this.character.center;
|
||||||
|
|
@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
return this.character;
|
return this.character;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sumDeaths++;
|
this.deaths++;
|
||||||
this.sumKills = this.character.killCount;
|
this.lastCharacter = this.character;
|
||||||
this.onCharacterDied(this.character);
|
|
||||||
this.character = null;
|
this.character = null;
|
||||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||||
this.onBeforeRespawn();
|
|
||||||
this.createCharacter();
|
this.createCharacter();
|
||||||
this.center = this.character!.center;
|
|
||||||
}
|
}
|
||||||
return undefined;
|
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 {
|
private findSpawnCenter(): vec2 {
|
||||||
const planets = this.objectContainer
|
const planets = planetsIn(
|
||||||
.findIntersecting(
|
this.objectContainer.findIntersecting(
|
||||||
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
|
BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2),
|
||||||
)
|
),
|
||||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
);
|
||||||
|
|
||||||
const friendly = planets.filter((p) => p.team === this.team);
|
const friendly = planets.filter((p) => p.team === this.team);
|
||||||
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
|
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));
|
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);
|
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
|
||||||
}
|
}
|
||||||
|
|
||||||
public restoreScore(kills: number, deaths: number) {
|
private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||||
this.sumKills = kills;
|
|
||||||
this.sumDeaths = deaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
|
||||||
let rotation = 0;
|
let rotation = 0;
|
||||||
let radius = 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 roomiestPosition = vec2.clone(preferredCenter);
|
||||||
let roomiestClearance = -Infinity;
|
let roomiestClearance = -Infinity;
|
||||||
for (let attempt = 0; attempt < 512; attempt++) {
|
for (let attempt = 0; attempt < 512; attempt++) {
|
||||||
const playerPosition = vec2.fromValues(
|
const position = vec2.fromValues(
|
||||||
radius * Math.cos(rotation) + preferredCenter.x,
|
radius * Math.cos(rotation) + preferredCenter[0],
|
||||||
radius * Math.sin(rotation) + preferredCenter.y,
|
radius * Math.sin(rotation) + preferredCenter[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
const playerBoundingCircle = new Circle(playerPosition, boundRadius);
|
const clearance = evaluateSdf(
|
||||||
|
position,
|
||||||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
this.objectContainer.findIntersecting(
|
||||||
const possibleIntersectors =
|
BoundingBox.ofCircle(position, boundRadius),
|
||||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
),
|
||||||
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors);
|
);
|
||||||
if (clearance >= playerBoundingCircle.radius) {
|
if (clearance >= boundRadius) {
|
||||||
return playerPosition;
|
return position;
|
||||||
}
|
}
|
||||||
if (clearance > roomiestClearance) {
|
if (clearance > roomiestClearance) {
|
||||||
roomiestClearance = clearance;
|
roomiestClearance = clearance;
|
||||||
roomiestPosition = playerPosition;
|
roomiestPosition = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
rotation += Math.PI / 8;
|
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 { Socket } from 'socket.io';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { NPC } from './npc';
|
import { NPC } from './npc';
|
||||||
import { Player } from './player';
|
import { Player } from './player';
|
||||||
import { PlayerBase } from './player-base';
|
import { PlayerBase, Score } from './player-base';
|
||||||
import { ServerFullError } from './server-full-error';
|
|
||||||
|
|
||||||
// Team and score carried over from the connection a reconnecting client is
|
export interface CarriedScore extends Score {
|
||||||
// replacing, so it resumes the match instead of starting from zero.
|
|
||||||
export interface CarriedScore {
|
|
||||||
team: CharacterTeam;
|
team: CharacterTeam;
|
||||||
kills: number;
|
|
||||||
deaths: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlayerContainer {
|
export class PlayerContainer {
|
||||||
private _players: Array<Player> = [];
|
private _players: Array<Player> = [];
|
||||||
private _npcs: Array<NPC> = [];
|
private npcs: Array<NPC> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly objects: PhysicalContainer,
|
private readonly objects: PhysicalContainer,
|
||||||
|
|
@ -26,14 +29,14 @@ export class PlayerContainer {
|
||||||
this.createNPCs();
|
this.createNPCs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public createNPCs() {
|
private createNPCs() {
|
||||||
const newNpcCount = Math.min(
|
const newNpcCount = Math.min(
|
||||||
this.playerMaxCount - this._players.length - this._npcs.length,
|
this.playerMaxCount - this._players.length - this.npcs.length,
|
||||||
this.npcMaxCount - this._npcs.length,
|
this.npcMaxCount - this.npcs.length,
|
||||||
);
|
);
|
||||||
for (let i = 0; i < newNpcCount; i++) {
|
for (let i = 0; i < newNpcCount; i++) {
|
||||||
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
const name = `🤖 ${Random.choose(settings.npcNames)}`;
|
||||||
this._npcs.push(
|
this.npcs.push(
|
||||||
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -44,31 +47,20 @@ export class PlayerContainer {
|
||||||
socket: Socket,
|
socket: Socket,
|
||||||
carried?: CarriedScore,
|
carried?: CarriedScore,
|
||||||
): Player {
|
): Player {
|
||||||
if (this._players.length >= this.playerMaxCount) {
|
|
||||||
throw new ServerFullError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = carried ? carried.team : this.getTeamOfNextPlayer();
|
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 =
|
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();
|
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);
|
this._players.push(player);
|
||||||
|
|
||||||
if (carried) {
|
|
||||||
player.restoreScore(carried.kills, carried.deaths);
|
|
||||||
}
|
|
||||||
|
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get players(): Array<PlayerBase> {
|
public get players(): Array<PlayerBase> {
|
||||||
return [...this._players, ...this._npcs];
|
return [...this._players, ...this.npcs];
|
||||||
}
|
}
|
||||||
|
|
||||||
public get count(): number {
|
public get count(): number {
|
||||||
|
|
@ -79,20 +71,22 @@ export class PlayerContainer {
|
||||||
return this._players.length >= this.playerMaxCount;
|
return this._players.length >= this.playerMaxCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real connected players only — NPCs have no socket and are excluded.
|
|
||||||
public get connectedPlayerRttsMs(): Array<number> {
|
public get connectedPlayerRttsMs(): Array<number> {
|
||||||
return this._players.map((p) => p.rttMs);
|
return this._players.map((p) => p.rttMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public step(deltaTimeInSeconds: number) {
|
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._players.forEach((p) => p.step(deltaTimeInSeconds));
|
||||||
this._npcs.forEach((p) => p.step(deltaTimeInSeconds));
|
this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
public stepCommunication(deltaTimeInSeconds: number) {
|
public stepCommunication(
|
||||||
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
|
deltaTimeInSeconds: number,
|
||||||
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
|
) {
|
||||||
|
this._players.forEach((p) =>
|
||||||
|
p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public endGame(winner: CharacterTeam) {
|
public endGame(winner: CharacterTeam) {
|
||||||
|
|
@ -120,12 +114,8 @@ export class PlayerContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public deletePlayer(player: Player) {
|
public deletePlayer(player: Player) {
|
||||||
const had = this._players.includes(player);
|
if (this._players.includes(player)) {
|
||||||
this._players = this._players.filter((p) => p !== 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();
|
this.createNPCs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,44 +31,42 @@ import {
|
||||||
isFiniteNumber,
|
isFiniteNumber,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
import { BoundingBox } from '../physics/bounding-box';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/physical-container';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
import { PlayerBase } from './player-base';
|
import { PlayerBase, Score } from './player-base';
|
||||||
import { CharacterPhysical } from '../objects/character-physical';
|
import { CharacterPhysical } from '../objects/character-physical';
|
||||||
|
|
||||||
const pingIntervalSeconds = 1;
|
const pingIntervalSeconds = 1;
|
||||||
|
|
||||||
const minimumAspectRatio = 0.2;
|
const minimumAspectRatio = 0.2;
|
||||||
const maximumAspectRatio = 8;
|
const maximumAspectRatio = 8;
|
||||||
|
|
||||||
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
|
const sheddableWhenBackedUp: ReadonlyArray<string> = [
|
||||||
|
PropertyUpdatesForObjects.type,
|
||||||
|
UpdateMinimap.type,
|
||||||
|
InputAcknowledgement.type,
|
||||||
|
];
|
||||||
|
|
||||||
export class Player extends PlayerBase {
|
export class Player extends PlayerBase {
|
||||||
private aspectRatio: number = 16 / 9;
|
public reconnectToken = '';
|
||||||
|
public rttMs = 0;
|
||||||
|
|
||||||
|
private aspectRatio = 16 / 9;
|
||||||
private timeSinceLastMessage = 0;
|
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 objectsInViewArea = new Set<GameObject>();
|
||||||
|
private commandsToBeSent: Array<Command> = [];
|
||||||
|
private winnerTeam?: CharacterTeam;
|
||||||
|
|
||||||
private lastInputClientTimeMs = 0;
|
private lastInputClientTimeMs = 0;
|
||||||
private lastInputReceiptMs = 0;
|
private lastInputReceiptMs = 0;
|
||||||
private lastLeapClientTimeMs = 0;
|
private lastLeapClientTimeMs = 0;
|
||||||
|
|
||||||
public reconnectToken = '';
|
|
||||||
|
|
||||||
// Server-side latency stat, reported in GameServer's periodic log.
|
|
||||||
public rttMs = 0;
|
|
||||||
private timeSinceLastPing = 0;
|
private timeSinceLastPing = 0;
|
||||||
private lastPingSentMs = 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 pendingPingNonce = 0;
|
||||||
private nextPingNonce = 1;
|
private nextPingNonce = 1;
|
||||||
|
|
||||||
// Input below is untrusted: every command arrives from `deserialize`
|
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
|
||||||
// (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.
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
|
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
|
||||||
this.aspectRatio = finiteInRange(
|
this.aspectRatio = finiteInRange(
|
||||||
|
|
@ -84,7 +82,7 @@ export class Player extends PlayerBase {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.observeClientTime(c.clientTimeMs);
|
this.observeClientTime(c.clientTimeMs);
|
||||||
this.character?.handleMovementAction(new MoveActionCommand(direction, 0));
|
this.character?.setMoveDirection(direction);
|
||||||
},
|
},
|
||||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
|
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
|
||||||
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
|
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
|
||||||
|
|
@ -98,48 +96,34 @@ export class Player extends PlayerBase {
|
||||||
if (!isFiniteNumber(c.clientTimeMs)) {
|
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Record receipt whether or not leap() accepts it: the predictor must
|
|
||||||
// stop replaying this leap either way.
|
|
||||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||||
this.observeClientTime(c.clientTimeMs);
|
this.observeClientTime(c.clientTimeMs);
|
||||||
this.character?.leap();
|
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) =>
|
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
|
||||||
this.observeClientTime(c.clientTimeMs),
|
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(
|
constructor(
|
||||||
playerInfo: PlayerInformation,
|
playerInfo: PlayerInformation,
|
||||||
playerContainer: PlayerContainer,
|
playerContainer: PlayerContainer,
|
||||||
objectContainer: PhysicalContainer,
|
objectContainer: PhysicalContainer,
|
||||||
team: CharacterTeam,
|
team: CharacterTeam,
|
||||||
private readonly socket: Socket,
|
private readonly socket: Socket,
|
||||||
|
score?: Score,
|
||||||
) {
|
) {
|
||||||
super(playerInfo, playerContainer, objectContainer, team);
|
super(playerInfo, playerContainer, objectContainer, team, score);
|
||||||
this.createCharacter();
|
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);
|
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the outstanding ping's nonce counts, once: a client cannot forge a
|
private observeClientTime(clientTimeMs: number) {
|
||||||
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to
|
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
|
||||||
// inflate the measurement is bounded by the clamp, and the next ping retires
|
this.lastInputClientTimeMs = clientTimeMs;
|
||||||
// the nonce, so any stall costs at most one ping interval.
|
this.lastInputReceiptMs = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readonly onPong = (nonce: unknown) => {
|
private readonly onPong = (nonce: unknown) => {
|
||||||
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
|
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
|
||||||
return;
|
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() {
|
public detachFromSocket() {
|
||||||
this.socket.off(TransportEvents.Pong, this.onPong);
|
this.socket.off(TransportEvents.Pong, this.onPong);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createCharacter() {
|
protected createCharacter(): CharacterPhysical {
|
||||||
super.createCharacter();
|
const character = super.createCharacter();
|
||||||
|
this.objectsInViewArea.add(character);
|
||||||
this.objectsInViewArea.add(this.character!);
|
this.queueCommandSend(new CreatePlayerCommand(character));
|
||||||
this.queueCommandSend(new CreatePlayerCommand(this.character!));
|
return character;
|
||||||
}
|
}
|
||||||
|
|
||||||
private winnerTeam?: CharacterTeam;
|
|
||||||
public onGameEnded(winnerTeam: CharacterTeam) {
|
public onGameEnded(winnerTeam: CharacterTeam) {
|
||||||
this.winnerTeam = winnerTeam;
|
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) {
|
public step(deltaTimeInSeconds: number) {
|
||||||
this.stepLifecycle(deltaTimeInSeconds);
|
this.stepLifecycle(deltaTimeInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onCharacterDied(character: CharacterPhysical) {
|
public queueCommandSend(command: Command) {
|
||||||
this.dyingCharacter = character;
|
this.commandsToBeSent.push(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onBeforeRespawn() {
|
public stepCommunications(
|
||||||
if (this.dyingCharacter) {
|
deltaTime: number,
|
||||||
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
this.dyingCharacter = null;
|
) {
|
||||||
|
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() {
|
private queueSnapshot(
|
||||||
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
const bb = new BoundingBox();
|
) {
|
||||||
bb.topLeft = viewArea.topLeft;
|
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||||
bb.size = viewArea.size;
|
const viewBox = new BoundingBox(
|
||||||
|
topLeft[0],
|
||||||
const inViewArea = new Set(
|
topLeft[0] + size[0],
|
||||||
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
|
topLeft[1] - size[1],
|
||||||
|
topLeft[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The owning character must always be in its own snapshot so the client
|
const inViewArea = new Set(
|
||||||
// predictor never loses its authoritative anchor.
|
this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
|
||||||
|
);
|
||||||
if (this.character) {
|
if (this.character) {
|
||||||
inViewArea.add(this.character);
|
inViewArea.add(this.character);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set membership rather than Array.includes: this diff runs per player per
|
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
|
||||||
// snapshot over everything on screen and was quadratic in that.
|
const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
|
||||||
const newlyIntersecting = [...inViewArea].filter(
|
|
||||||
(o) => !this.objectsInViewArea.has(o),
|
|
||||||
);
|
|
||||||
|
|
||||||
const noLongerIntersecting = [...this.objectsInViewArea].filter(
|
|
||||||
(o) => !inViewArea.has(o),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.objectsInViewArea = inViewArea;
|
this.objectsInViewArea = inViewArea;
|
||||||
|
|
||||||
if (noLongerIntersecting.length > 0) {
|
if (deleted.length > 0) {
|
||||||
this.queueCommandSend(
|
this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
|
||||||
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (created.length > 0) {
|
||||||
if (newlyIntersecting.length > 0) {
|
this.queueCommandSend(new CreateObjectsCommand(created));
|
||||||
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
|
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
|
||||||
|
|
||||||
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
|
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
|
||||||
for (const object of this.objectsInViewArea) {
|
for (const object of this.objectsInViewArea) {
|
||||||
const update = object.getPropertyUpdatesForFrame();
|
const update = propertyUpdatesOf(object);
|
||||||
if (update) {
|
if (update) {
|
||||||
propertyUpdates.push(update);
|
propertyUpdates.push(update);
|
||||||
}
|
}
|
||||||
|
|
@ -239,8 +230,6 @@ export class Player extends PlayerBase {
|
||||||
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
|
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) {
|
if (this.character) {
|
||||||
this.queueCommandSend(
|
this.queueCommandSend(
|
||||||
new InputAcknowledgement(
|
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> {
|
private getMinimapPlayers(): Array<MinimapPlayer> {
|
||||||
return this.playerContainer.players
|
return this.playerContainer.players
|
||||||
.filter((p) => p !== this && p.character?.isAlive)
|
.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 {
|
private get bufferedBytes(): number {
|
||||||
const conn = this.socket.conn as
|
const conn = this.socket.conn as
|
||||||
| { transport?: { socket?: { bufferedAmount?: unknown } } }
|
| { transport?: { socket?: { bufferedAmount?: unknown } } }
|
||||||
|
|
@ -311,53 +262,31 @@ export class Player extends PlayerBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendQueuedCommandsToClient() {
|
public sendQueuedCommandsToClient() {
|
||||||
if (this.commandsToBeSent.length === 0) {
|
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
|
||||||
return;
|
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
];
|
|
||||||
this.commandsToBeSent = this.commandsToBeSent.filter(
|
this.commandsToBeSent = this.commandsToBeSent.filter(
|
||||||
(c) => !sheddable.includes(c.type),
|
(c) => !sheddableWhenBackedUp.includes(c.type),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (this.commandsToBeSent.length === 0) {
|
if (this.commandsToBeSent.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
||||||
this.commandsToBeSent = [];
|
this.commandsToBeSent = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public get scoreSnapshot(): { kills: number; deaths: number } {
|
private queueAnnouncement() {
|
||||||
return {
|
|
||||||
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
|
|
||||||
deaths: this.sumDeaths,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleAnnouncements() {
|
|
||||||
let announcement = '';
|
|
||||||
if (this.winnerTeam) {
|
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) {
|
} else if (!this.character) {
|
||||||
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
|
this.queueCommandSend(
|
||||||
}
|
new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
|
||||||
|
);
|
||||||
if (announcement) {
|
|
||||||
this.queueCommandSend(new ServerAnnouncement(announcement));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ module.exports = (env, argv) => ({
|
||||||
minimizer: [
|
minimizer: [
|
||||||
new TerserJSPlugin({
|
new TerserJSPlugin({
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
// The custom serialization protocol keys on class names, so they must
|
// The serialization protocol keys on class names.
|
||||||
// survive minification (see shared/src/serialization).
|
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
keep_classnames: true,
|
keep_classnames: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json.
|
|
||||||
const tseslint = require('typescript-eslint');
|
const tseslint = require('typescript-eslint');
|
||||||
const prettierRecommended = require('eslint-plugin-prettier/recommended');
|
const prettierRecommended = require('eslint-plugin-prettier/recommended');
|
||||||
const unusedImports = require('eslint-plugin-unused-imports');
|
const unusedImports = require('eslint-plugin-unused-imports');
|
||||||
|
|
@ -25,7 +24,6 @@ module.exports = tseslint.config(
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
// Renamed from the former no-var-requires; keep require() allowed.
|
|
||||||
'@typescript-eslint/no-require-imports': 'off',
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
applyArrayPlugins,
|
|
||||||
LampBase,
|
LampBase,
|
||||||
overrideDeserialization,
|
overrideDeserialization,
|
||||||
PlanetBase,
|
PlanetBase,
|
||||||
|
|
@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png';
|
||||||
import '../static/favicons/favicon-16x16.png';
|
import '../static/favicons/favicon-16x16.png';
|
||||||
import '../static/favicons/favicon-32x32.png';
|
import '../static/favicons/favicon-32x32.png';
|
||||||
import '../static/favicons/favicon.ico';
|
import '../static/favicons/favicon.ico';
|
||||||
|
import ResizeObserver from 'resize-observer-polyfill';
|
||||||
import { LandingPageBackground } from './scripts/landing-page-background';
|
import { LandingPageBackground } from './scripts/landing-page-background';
|
||||||
import { JoinFormHandler } from './scripts/join-form-handler';
|
import { JoinFormHandler } from './scripts/join-form-handler';
|
||||||
import { handleFullScreen } from './scripts/helper/handle-full-screen';
|
import { handleFullScreen } from './scripts/helper/handle-full-screen';
|
||||||
import { Game } from './scripts/game';
|
import { Game } from './scripts/game';
|
||||||
import ResizeObserver from 'resize-observer-polyfill';
|
import { initializeOptions } from './scripts/options-handler';
|
||||||
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 { SoundHandler, Sounds } from './scripts/sound-handler';
|
import { SoundHandler, Sounds } from './scripts/sound-handler';
|
||||||
import { VibrationHandler } from './scripts/vibration-handler';
|
import { VibrationHandler } from './scripts/vibration-handler';
|
||||||
import { CharacterView } from './scripts/objects/types/character-view';
|
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';
|
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
applyArrayPlugins();
|
|
||||||
|
|
||||||
overrideDeserialization(CharacterBase, CharacterView);
|
overrideDeserialization(CharacterBase, CharacterView);
|
||||||
overrideDeserialization(PlanetBase, PlanetView);
|
overrideDeserialization(PlanetBase, PlanetView);
|
||||||
overrideDeserialization(LampBase, LampView);
|
overrideDeserialization(LampBase, LampView);
|
||||||
overrideDeserialization(ProjectileBase, ProjectileView);
|
overrideDeserialization(ProjectileBase, ProjectileView);
|
||||||
|
|
||||||
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
|
const query = <T extends HTMLElement>(selector: string) =>
|
||||||
const nameInput = document.querySelector('#name') as HTMLInputElement;
|
document.querySelector(selector) as T;
|
||||||
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
|
|
||||||
const serverContainer = document.querySelector('#server-container') as HTMLElement;
|
const landingUI = query('#landing-ui');
|
||||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
const nameInput = query<HTMLInputElement>('#name');
|
||||||
const overlay = document.querySelector('#overlay') as HTMLElement;
|
const joinGameForm = query<HTMLFormElement>('#join-game-form');
|
||||||
const settings = document.querySelector('#settings') as HTMLElement;
|
const serverContainer = query('#server-container');
|
||||||
const toggleSettingsButton = document.querySelector(
|
const canvas = query<HTMLCanvasElement>('canvas');
|
||||||
'#toggle-settings-container',
|
const overlay = query('#overlay');
|
||||||
) as HTMLElement;
|
const settings = query('#settings');
|
||||||
const minimize = document.querySelector('#minimize') as HTMLElement;
|
const toggleSettingsButton = query('#toggle-settings-container');
|
||||||
const maximize = document.querySelector('#maximize') as HTMLElement;
|
const minimize = query('#minimize');
|
||||||
const logoutButton = document.querySelector('#logout') as HTMLElement;
|
const maximize = query('#maximize');
|
||||||
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
|
const logoutButton = query('#logout');
|
||||||
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
const enableSounds = query<HTMLInputElement>('#enable-sounds');
|
||||||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
const enableMusic = query<HTMLInputElement>('#enable-music');
|
||||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
const enableVibration = query<HTMLInputElement>('#enable-vibration');
|
||||||
|
const spinner = query('#spinner-container');
|
||||||
|
|
||||||
const joinNotice = document.createElement('p');
|
const joinNotice = document.createElement('p');
|
||||||
joinNotice.className = 'join-notice';
|
joinNotice.className = 'join-notice';
|
||||||
joinNotice.style.display = 'none';
|
joinNotice.style.display = 'none';
|
||||||
joinGameForm.prepend(joinNotice);
|
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 = () => {
|
const toggleSettings = () => {
|
||||||
settings.className = settings.className === 'open' ? '' : 'open';
|
settings.className = settings.className === 'open' ? '' : 'open';
|
||||||
SoundHandler.play(Sounds.click);
|
SoundHandler.play(Sounds.click);
|
||||||
|
|
@ -76,21 +74,19 @@ const applyServerContainerShadows = () => {
|
||||||
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
|
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
|
||||||
|
|
||||||
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
|
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
|
||||||
if (scrollHeight > clientHeight) {
|
if (scrollHeight <= clientHeight) {
|
||||||
if (scrollTop <= 0) {
|
serverContainer.style.boxShadow = '';
|
||||||
|
} else if (scrollTop <= 0) {
|
||||||
serverContainer.style.boxShadow = topShadow;
|
serverContainer.style.boxShadow = topShadow;
|
||||||
} else if (scrollTop + clientHeight >= scrollHeight) {
|
} else if (scrollTop + clientHeight >= scrollHeight) {
|
||||||
serverContainer.style.boxShadow = bottomShadow;
|
serverContainer.style.boxShadow = bottomShadow;
|
||||||
} else {
|
} else {
|
||||||
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
|
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
serverContainer.style.boxShadow = '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
let game: Game;
|
let game: Game | undefined;
|
||||||
|
|
||||||
const storedUserName = localStorage.getItem('userName');
|
const storedUserName = localStorage.getItem('userName');
|
||||||
if (storedUserName) {
|
if (storedUserName) {
|
||||||
|
|
@ -113,7 +109,7 @@ const main = async () => {
|
||||||
document.addEventListener('click', firstClickListener);
|
document.addEventListener('click', firstClickListener);
|
||||||
|
|
||||||
if (!VibrationHandler.isVibrationEnabledHeuristics) {
|
if (!VibrationHandler.isVibrationEnabledHeuristics) {
|
||||||
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
|
setDisplayed(query("label[for='enable-vibration']"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFullScreen(minimize, maximize);
|
handleFullScreen(minimize, maximize);
|
||||||
|
|
@ -122,28 +118,28 @@ const main = async () => {
|
||||||
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
|
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
|
||||||
serverContainer.addEventListener('scroll', applyServerContainerShadows);
|
serverContainer.addEventListener('scroll', applyServerContainerShadows);
|
||||||
|
|
||||||
OptionsHandler.initialize({
|
initializeOptions({
|
||||||
soundsEnabled: enableSounds,
|
soundsEnabled: enableSounds,
|
||||||
vibrationEnabled: enableVibration,
|
vibrationEnabled: enableVibration,
|
||||||
musicEnabled: enableMusic,
|
musicEnabled: enableMusic,
|
||||||
});
|
});
|
||||||
|
|
||||||
logoutButton.addEventListener('click', () => {
|
logoutButton.addEventListener('click', () => {
|
||||||
game.destroy();
|
game?.destroy();
|
||||||
toggleSettings();
|
toggleSettings();
|
||||||
});
|
});
|
||||||
window.onpopstate = () => game.destroy();
|
window.onpopstate = () => game?.destroy();
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
show(spinner);
|
setVisible(spinner, true);
|
||||||
hide(logoutButton, true);
|
setDisplayed(logoutButton, null);
|
||||||
show(landingUI, true, 'flex');
|
setDisplayed(landingUI, 'flex');
|
||||||
|
|
||||||
const background = new LandingPageBackground(canvas);
|
const background = new LandingPageBackground(canvas);
|
||||||
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
|
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
|
||||||
|
|
||||||
await background.renderer;
|
await background.renderer;
|
||||||
hide(spinner);
|
setVisible(spinner, false);
|
||||||
|
|
||||||
const playerDecision = await joinHandler.getPlayerDecision();
|
const playerDecision = await joinHandler.getPlayerDecision();
|
||||||
|
|
||||||
|
|
@ -153,29 +149,25 @@ const main = async () => {
|
||||||
history.pushState(true, '');
|
history.pushState(true, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
hide(landingUI, true);
|
setDisplayed(landingUI, null);
|
||||||
show(spinner);
|
setVisible(spinner, true);
|
||||||
background.destroy();
|
background.destroy();
|
||||||
game = new Game(playerDecision, canvas, overlay);
|
game = new Game(playerDecision, canvas, overlay);
|
||||||
const gameOver = game.start();
|
const gameOver = game.start();
|
||||||
await game.started;
|
await game.started;
|
||||||
hide(spinner);
|
setVisible(spinner, false);
|
||||||
show(logoutButton, true, 'block');
|
setDisplayed(logoutButton, 'block');
|
||||||
await gameOver;
|
await gameOver;
|
||||||
|
|
||||||
const reason = game.lastRejectionReason;
|
const reason = game.rejectionReason;
|
||||||
if (reason) {
|
joinNotice.innerText = reason ? Game.rejectionText(reason) : '';
|
||||||
joinNotice.innerText = Game.rejectionText(reason);
|
setDisplayed(joinNotice, reason ? 'block' : null);
|
||||||
joinNotice.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
joinNotice.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
hide(spinner);
|
setVisible(spinner, false);
|
||||||
joinNotice.innerText = 'Something went wrong. Please reload the page.';
|
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';
|
import { pointer } from './helper/pointer';
|
||||||
|
|
||||||
let element: HTMLElement | undefined;
|
let element: HTMLElement | undefined;
|
||||||
|
|
@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) {
|
||||||
function endChargeIndicator() {
|
function endChargeIndicator() {
|
||||||
if (element) {
|
if (element) {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
element.parentElement?.removeChild(element);
|
element.remove();
|
||||||
element = undefined;
|
element = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ function updateChargeIndicator() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000);
|
const charge = chargeHeldSince(heldSince);
|
||||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
||||||
charge * 360
|
charge * 360
|
||||||
|
|
@ -44,15 +44,14 @@ function updateChargeIndicator() {
|
||||||
if (isFollowingPointer) {
|
if (isFollowingPointer) {
|
||||||
const cursor = pointer.displayPosition;
|
const cursor = pointer.displayPosition;
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
element.style.left = `${cursor.x}px`;
|
element.style.left = `${cursor[0]}px`;
|
||||||
element.style.top = `${cursor.y}px`;
|
element.style.top = `${cursor[1]}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raf = requestAnimationFrame(updateChargeIndicator);
|
raf = requestAnimationFrame(updateChargeIndicator);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The one public surface; the functions above stay module-private.
|
|
||||||
export const ChargeIndicator = {
|
export const ChargeIndicator = {
|
||||||
begin: beginChargeIndicator,
|
begin: beginChargeIndicator,
|
||||||
end: endChargeIndicator,
|
end: endChargeIndicator,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue