more
This commit is contained in:
parent
98c4d63756
commit
82e092ba55
14 changed files with 373 additions and 539 deletions
|
|
@ -4,9 +4,9 @@ import { pointer } from './helper/pointer';
|
|||
let element: HTMLElement | undefined;
|
||||
let heldSince = 0;
|
||||
let raf = 0;
|
||||
let _followPointer = false;
|
||||
let isFollowingPointer = false;
|
||||
|
||||
export function beginChargeIndicator(x: number, y: number, followPointer = false) {
|
||||
function beginChargeIndicator(x: number, y: number, followPointer = false) {
|
||||
endChargeIndicator();
|
||||
|
||||
const el = document.createElement('div');
|
||||
|
|
@ -17,11 +17,11 @@ export function beginChargeIndicator(x: number, y: number, followPointer = false
|
|||
|
||||
element = el;
|
||||
heldSince = performance.now();
|
||||
_followPointer = followPointer;
|
||||
isFollowingPointer = followPointer;
|
||||
raf = requestAnimationFrame(updateChargeIndicator);
|
||||
}
|
||||
|
||||
export function endChargeIndicator() {
|
||||
function endChargeIndicator() {
|
||||
if (element) {
|
||||
cancelAnimationFrame(raf);
|
||||
element.parentElement?.removeChild(element);
|
||||
|
|
@ -41,7 +41,7 @@ function updateChargeIndicator() {
|
|||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||
element.classList.toggle('full', charge >= 1);
|
||||
|
||||
if (_followPointer) {
|
||||
if (isFollowingPointer) {
|
||||
const cursor = pointer.displayPosition;
|
||||
if (cursor) {
|
||||
element.style.left = `${cursor.x}px`;
|
||||
|
|
@ -52,6 +52,7 @@ function updateChargeIndicator() {
|
|||
raf = requestAnimationFrame(updateChargeIndicator);
|
||||
}
|
||||
|
||||
// The one public surface; the functions above stay module-private.
|
||||
export const ChargeIndicator = {
|
||||
begin: beginChargeIndicator,
|
||||
end: endChargeIndicator,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,17 @@ export class CommandSocket extends CommandReceiver {
|
|||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
// Nothing goes out while the transport is down. socket.io-client would
|
||||
// otherwise buffer every emit in an uncapped sendBuffer and flush it on
|
||||
// reconnect BEFORE the `connect` handler re-sends PlayerJoining — the
|
||||
// server has no PlayerToServer listener attached yet and drops the lot.
|
||||
// The local queue is dropped with it: these are timestamped inputs, and
|
||||
// replaying a minute-old shot after the gap would be worse than losing it.
|
||||
if (!this.socket.connected) {
|
||||
this.commandQueue = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = predictorNowMs();
|
||||
|
||||
// Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good.
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class BeforeDestroyCommand extends Command {
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Renderer } from 'sdf-2d';
|
||||
import { Command } from 'shared';
|
||||
|
||||
export class RenderCommand extends Command {
|
||||
public constructor(
|
||||
public readonly renderer: Renderer,
|
||||
public readonly overlay: HTMLElement,
|
||||
public readonly shouldChangeLayout: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
public constructor(public readonly deltaTimeInSeconds: number) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ function addTransient(el: HTMLElement, lifetimeMs: number) {
|
|||
setTimeout(() => el.parentElement?.removeChild(el), lifetimeMs);
|
||||
}
|
||||
|
||||
export function hitMarker(charge = 0) {
|
||||
function hitMarker(charge = 0) {
|
||||
const { x, y } = focusPoint();
|
||||
const marker = document.createElement('div');
|
||||
marker.className =
|
||||
|
|
@ -43,7 +43,7 @@ export function hitMarker(charge = 0) {
|
|||
addTransient(marker, 250);
|
||||
}
|
||||
|
||||
export function killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||
function killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||
const { killfeed: kf } = ensureRoot();
|
||||
const charged = charge >= settings.chargedHitThreshold;
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ export function killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
|||
}
|
||||
}
|
||||
|
||||
export function showElimination(): void {
|
||||
function showElimination(): void {
|
||||
if (elimination) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -88,12 +88,12 @@ export function showElimination(): void {
|
|||
elimination = el;
|
||||
}
|
||||
|
||||
export function hideElimination(): void {
|
||||
function hideElimination(): void {
|
||||
elimination?.parentElement?.removeChild(elimination);
|
||||
elimination = undefined;
|
||||
}
|
||||
|
||||
export function resetFeedbackHud(): void {
|
||||
function resetFeedbackHud(): void {
|
||||
hideElimination();
|
||||
root?.parentElement?.removeChild(root);
|
||||
root = undefined;
|
||||
|
|
@ -119,6 +119,7 @@ function escapeHtml(text: string): string {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// The one public surface; the functions above stay module-private.
|
||||
export const FeedbackHud = {
|
||||
hitMarker,
|
||||
killConfirmed,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ import { Minimap } from './minimap';
|
|||
import { ScreenShake } from './screen-shake';
|
||||
import { FeedbackHud } from './feedback-hud';
|
||||
|
||||
// Ten attempts at the configured backoff is on the order of a minute of
|
||||
// retrying before the client gives up and returns to the landing page.
|
||||
const maximumReconnectionAttempts = 10;
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
public renderer?: Renderer;
|
||||
|
|
@ -106,6 +110,10 @@ export class Game extends CommandReceiver {
|
|||
|
||||
this.socket = io(this.playerDecision.server, {
|
||||
reconnectionDelayMax: 10000,
|
||||
// Must be finite. The default is Infinity, and with it the manager never
|
||||
// gives up, so `reconnect_failed` below never fires and a client whose
|
||||
// server is gone sits on a frozen world behind the banner forever.
|
||||
reconnectionAttempts: maximumReconnectionAttempts,
|
||||
transports: ['websocket'],
|
||||
forceNew: true,
|
||||
parser,
|
||||
|
|
@ -138,6 +146,14 @@ export class Game extends CommandReceiver {
|
|||
...this.playerDecision,
|
||||
reconnectToken: this.reconnectToken,
|
||||
});
|
||||
// A reconnect gets a brand-new server-side Player, back on the default
|
||||
// aspect ratio; the camera only reports a ratio when it changes, so
|
||||
// without this re-send it would stay wrong for the rest of the match.
|
||||
if (this.lastAspectRatio !== undefined) {
|
||||
this.socketReceiver.handleCommand(
|
||||
new SetAspectRatioActionCommand(this.lastAspectRatio),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.io.on('reconnect_failed', () => {
|
||||
|
|
@ -303,7 +319,9 @@ export class Game extends CommandReceiver {
|
|||
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
|
||||
}
|
||||
|
||||
private lastAspectRatio?: number;
|
||||
public aspectRatioChanged(aspectRatio: number) {
|
||||
this.lastAspectRatio = aspectRatio;
|
||||
this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { Circle } from 'shared';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
import { Vec2Interpolator } from './vec2-interpolator';
|
||||
|
||||
export class CircleInterpolator {
|
||||
private center: Vec2Interpolator;
|
||||
private radius: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: Circle) {
|
||||
this.center = new Vec2Interpolator(currentValue.center);
|
||||
this.radius = new LinearInterpolator(currentValue.radius);
|
||||
}
|
||||
|
||||
public addFrame(value: Circle, rateOfChange: Circle) {
|
||||
this.center.addFrame(value.center, rateOfChange.center);
|
||||
this.radius.addFrame(value.radius, rateOfChange.radius);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): Circle {
|
||||
return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
|
||||
// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin.
|
||||
export class Vec2Interpolator {
|
||||
private x: LinearInterpolator;
|
||||
private y: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: vec2) {
|
||||
this.x = new LinearInterpolator(currentValue[0]);
|
||||
this.y = new LinearInterpolator(currentValue[1]);
|
||||
}
|
||||
|
||||
public addFrame(value: vec2, rateOfChange: vec2) {
|
||||
this.x.addFrame(value[0], rateOfChange[0]);
|
||||
this.y.addFrame(value[1], rateOfChange[1]);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): vec2 {
|
||||
return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
calculateViewArea,
|
||||
clamp01,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
mix,
|
||||
mixRgb,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
|
|
@ -60,16 +59,9 @@ export class Camera extends CommandReceiver {
|
|||
const viewArea = calculateViewArea(shakenCenter, canvasAspectRatio, scale * scale);
|
||||
renderer.setViewArea(viewArea.topLeft, viewArea.size);
|
||||
|
||||
const [near, far] = settings.backgroundGradient;
|
||||
renderer.setRuntimeSettings({
|
||||
ambientLight: (() => {
|
||||
const q = clamp01(vec2.length(this.center) / settings.worldRadius);
|
||||
const [a, b] = settings.backgroundGradient;
|
||||
return vec3.fromValues(
|
||||
mix(a[0], b[0], q),
|
||||
mix(a[1], b[1], q),
|
||||
mix(a[2], b[2], q),
|
||||
);
|
||||
})(),
|
||||
ambientLight: mixRgb(near, far, vec2.length(this.center) / settings.worldRadius),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator';
|
||||
import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
|
||||
import { pointer } from '../../helper/pointer';
|
||||
import { CharacterShape } from '../../shapes/character-shape';
|
||||
|
|
@ -59,15 +60,9 @@ export class CharacterView extends CharacterBase {
|
|||
|
||||
public isMainCharacter = false;
|
||||
|
||||
private lfx = new LinearInterpolator(0);
|
||||
private lfy = new LinearInterpolator(0);
|
||||
private lfr = new LinearInterpolator(0);
|
||||
private rfx = new LinearInterpolator(0);
|
||||
private rfy = new LinearInterpolator(0);
|
||||
private rfr = new LinearInterpolator(0);
|
||||
private hdx = new LinearInterpolator(0);
|
||||
private hdy = new LinearInterpolator(0);
|
||||
private hdr = new LinearInterpolator(0);
|
||||
private headInterpolator: CircleInterpolator;
|
||||
private leftFootInterpolator: CircleInterpolator;
|
||||
private rightFootInterpolator: CircleInterpolator;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[RenderCommand.type]: this.draw.bind(this),
|
||||
|
|
@ -96,18 +91,9 @@ export class CharacterView extends CharacterBase {
|
|||
);
|
||||
this.deathBurst = new CircleLight(vec2.clone(this.head!.center), deathBurstColor, 0);
|
||||
|
||||
const lf = this.leftFoot!;
|
||||
this.lfx = new LinearInterpolator(lf.center[0]);
|
||||
this.lfy = new LinearInterpolator(lf.center[1]);
|
||||
this.lfr = new LinearInterpolator(lf.radius);
|
||||
const rf = this.rightFoot!;
|
||||
this.rfx = new LinearInterpolator(rf.center[0]);
|
||||
this.rfy = new LinearInterpolator(rf.center[1]);
|
||||
this.rfr = new LinearInterpolator(rf.radius);
|
||||
const hd = this.head!;
|
||||
this.hdx = new LinearInterpolator(hd.center[0]);
|
||||
this.hdy = new LinearInterpolator(hd.center[1]);
|
||||
this.hdr = new LinearInterpolator(hd.radius);
|
||||
this.headInterpolator = new CircleInterpolator(this.head!);
|
||||
this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!);
|
||||
this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!);
|
||||
|
||||
this.nameElement.className = 'player-tag ' + this.team;
|
||||
this.nameElement.innerText = this.name;
|
||||
|
|
@ -163,19 +149,13 @@ export class CharacterView extends CharacterBase {
|
|||
rateOfChange,
|
||||
}: UpdatePropertyCommand) {
|
||||
if (propertyKey === 'head') {
|
||||
this.hdx.addFrame(propertyValue.center[0], rateOfChange.center[0]);
|
||||
this.hdy.addFrame(propertyValue.center[1], rateOfChange.center[1]);
|
||||
this.hdr.addFrame(propertyValue.radius, rateOfChange.radius);
|
||||
this.headInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
if (propertyKey === 'leftFoot') {
|
||||
this.lfx.addFrame(propertyValue.center[0], rateOfChange.center[0]);
|
||||
this.lfy.addFrame(propertyValue.center[1], rateOfChange.center[1]);
|
||||
this.lfr.addFrame(propertyValue.radius, rateOfChange.radius);
|
||||
this.leftFootInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
if (propertyKey === 'rightFoot') {
|
||||
this.rfx.addFrame(propertyValue.center[0], rateOfChange.center[0]);
|
||||
this.rfy.addFrame(propertyValue.center[1], rateOfChange.center[1]);
|
||||
this.rfr.addFrame(propertyValue.radius, rateOfChange.radius);
|
||||
this.rightFootInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
if (propertyKey === 'strength') {
|
||||
this.strengthInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
|
|
@ -242,27 +222,9 @@ export class CharacterView extends CharacterBase {
|
|||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
this.head! = new Circle(
|
||||
vec2.fromValues(
|
||||
this.hdx.getValue(deltaTimeInSeconds),
|
||||
this.hdy.getValue(deltaTimeInSeconds),
|
||||
),
|
||||
this.hdr.getValue(deltaTimeInSeconds),
|
||||
);
|
||||
this.leftFoot! = new Circle(
|
||||
vec2.fromValues(
|
||||
this.lfx.getValue(deltaTimeInSeconds),
|
||||
this.lfy.getValue(deltaTimeInSeconds),
|
||||
),
|
||||
this.lfr.getValue(deltaTimeInSeconds),
|
||||
);
|
||||
this.rightFoot! = new Circle(
|
||||
vec2.fromValues(
|
||||
this.rfx.getValue(deltaTimeInSeconds),
|
||||
this.rfy.getValue(deltaTimeInSeconds),
|
||||
),
|
||||
this.rfr.getValue(deltaTimeInSeconds),
|
||||
);
|
||||
this.head! = this.headInterpolator.getValue(deltaTimeInSeconds);
|
||||
this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds);
|
||||
this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds);
|
||||
|
||||
this.strength = clamp(
|
||||
this.strengthInterpolator.getValue(deltaTimeInSeconds),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { CircleLight } from 'sdf-2d';
|
||||
import { clamp01, CommandExecutors, Id, LampBase, mix, settings } from 'shared';
|
||||
import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
|
||||
|
|
@ -29,12 +29,7 @@ export class LampView extends LampBase {
|
|||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds);
|
||||
const q = clamp01(t);
|
||||
this.light.color = vec3.fromValues(
|
||||
mix(this.light.color[0], this.targetColor[0], q),
|
||||
mix(this.light.color[1], this.targetColor[1], q),
|
||||
mix(this.light.color[2], this.targetColor[2], q),
|
||||
);
|
||||
this.light.color = mixRgb(this.light.color, this.targetColor, t);
|
||||
this.light.intensity += (this.targetLightness - this.light.intensity) * t;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,12 @@ import {
|
|||
} from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
|
||||
import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator';
|
||||
|
||||
export class ProjectileView extends ProjectileBase {
|
||||
private light: CircleLight;
|
||||
|
||||
private centerX = new LinearInterpolator(0);
|
||||
private centerY = new LinearInterpolator(0);
|
||||
private centerInterpolator: Vec2Interpolator;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[RenderCommand.type]: this.draw.bind(this),
|
||||
|
|
@ -37,22 +36,17 @@ export class ProjectileView extends ProjectileBase {
|
|||
settings.paletteDim[settings.colorIndices[team]],
|
||||
0,
|
||||
);
|
||||
this.centerX = new LinearInterpolator(center[0]);
|
||||
this.centerY = new LinearInterpolator(center[1]);
|
||||
this.centerInterpolator = new Vec2Interpolator(center);
|
||||
}
|
||||
|
||||
private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void {
|
||||
this.centerX.addFrame(propertyValue[0], rateOfChange[0]);
|
||||
this.centerY.addFrame(propertyValue[1], rateOfChange[1]);
|
||||
this.centerInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
|
||||
private handleStep({ deltaTimeInSeconds }: StepCommand): void {
|
||||
this.step(deltaTimeInSeconds);
|
||||
|
||||
this.center = vec2.fromValues(
|
||||
this.centerX.getValue(deltaTimeInSeconds),
|
||||
this.centerY.getValue(deltaTimeInSeconds),
|
||||
);
|
||||
this.center = this.centerInterpolator.getValue(deltaTimeInSeconds);
|
||||
this.light.center = this.center;
|
||||
this.light.intensity = Math.min(
|
||||
0.1,
|
||||
|
|
|
|||
|
|
@ -17,21 +17,21 @@ function reducedMotion(): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
export function addTrauma(t: number): void {
|
||||
function addTrauma(t: number): void {
|
||||
if (reducedMotion()) {
|
||||
return;
|
||||
}
|
||||
trauma = Math.min(1, trauma + t);
|
||||
}
|
||||
|
||||
export function addPunch(amount: number): void {
|
||||
function addPunch(amount: number): void {
|
||||
if (reducedMotion()) {
|
||||
return;
|
||||
}
|
||||
punch = Math.min(1, punch + amount);
|
||||
}
|
||||
|
||||
export function stepScreenShake(deltaTimeInSeconds: number): void {
|
||||
function stepScreenShake(deltaTimeInSeconds: number): void {
|
||||
trauma = Math.max(0, trauma - traumaDecayPerSecond * deltaTimeInSeconds);
|
||||
punch = Math.max(0, punch - punchDecayPerSecond * deltaTimeInSeconds);
|
||||
const shake = trauma * trauma;
|
||||
|
|
@ -39,37 +39,27 @@ export function stepScreenShake(deltaTimeInSeconds: number): void {
|
|||
offsetYValue = maxTranslation * shake * (Math.random() * 2 - 1);
|
||||
}
|
||||
|
||||
function getOffsetX(): number {
|
||||
return offsetXValue;
|
||||
}
|
||||
|
||||
function getOffsetY(): number {
|
||||
return offsetYValue;
|
||||
}
|
||||
|
||||
function getViewScale(): number {
|
||||
return 1 - maxZoom * punch * punch;
|
||||
}
|
||||
|
||||
export function resetScreenShake(): void {
|
||||
function resetScreenShake(): void {
|
||||
trauma = 0;
|
||||
punch = 0;
|
||||
offsetXValue = 0;
|
||||
offsetYValue = 0;
|
||||
}
|
||||
|
||||
// The one public surface. The functions above stay module-private so each
|
||||
// operation has exactly one name.
|
||||
export const ScreenShake = {
|
||||
add: addTrauma,
|
||||
addPunch,
|
||||
step: stepScreenShake,
|
||||
get offsetX() {
|
||||
return getOffsetX();
|
||||
return offsetXValue;
|
||||
},
|
||||
get offsetY() {
|
||||
return getOffsetY();
|
||||
return offsetYValue;
|
||||
},
|
||||
get viewScale() {
|
||||
return getViewScale();
|
||||
return 1 - maxZoom * punch * punch;
|
||||
},
|
||||
reset: resetScreenShake,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue