More fun
This commit is contained in:
parent
a1fb6755c7
commit
fc9df09ee1
36 changed files with 2011 additions and 251 deletions
|
|
@ -207,6 +207,14 @@ body {
|
|||
@include square(50px);
|
||||
border-radius: 1000px;
|
||||
mask-image: url('../static/mask.svg');
|
||||
|
||||
&.contested {
|
||||
animation: contested-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.keystone {
|
||||
@include square(72px);
|
||||
}
|
||||
}
|
||||
|
||||
.falling-point {
|
||||
|
|
@ -244,6 +252,34 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
|
||||
.keystone-arrow {
|
||||
transition: transform 150ms;
|
||||
opacity: 0.9;
|
||||
|
||||
@include square($large-icon);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square($small-icon);
|
||||
}
|
||||
|
||||
mask-image: url('../static/chevron.svg');
|
||||
mask-size: contain;
|
||||
background-color: $bright-neutral;
|
||||
|
||||
&.blue {
|
||||
background-color: $bright-blue;
|
||||
}
|
||||
|
||||
&.red {
|
||||
background-color: $bright-red;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background-color: $bright-neutral;
|
||||
}
|
||||
}
|
||||
|
||||
.joystick {
|
||||
@include square($large-icon * 1.3);
|
||||
background-color: white;
|
||||
|
|
@ -296,6 +332,15 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
&.leap {
|
||||
right: $medium-padding;
|
||||
bottom: $medium-padding + $large-icon * 1.6 + $small-padding;
|
||||
|
||||
&::after {
|
||||
content: '\25B2';
|
||||
}
|
||||
}
|
||||
|
||||
.strength-ring {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
|
|
@ -572,6 +617,16 @@ body {
|
|||
}
|
||||
|
||||
animation: hitmarker-pop 250ms ease-out forwards;
|
||||
|
||||
&.charged {
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
background-color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kill-popup {
|
||||
|
|
@ -584,6 +639,10 @@ body {
|
|||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
animation: kill-popup-rise 1200ms ease-out forwards;
|
||||
|
||||
&.charged {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.heal {
|
||||
color: #6fcf6f;
|
||||
}
|
||||
|
|
@ -626,6 +685,18 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes contested-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.charge-ring {
|
||||
position: fixed;
|
||||
@include square(56px);
|
||||
|
|
@ -649,6 +720,7 @@ body {
|
|||
}
|
||||
|
||||
@keyframes match-point-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px 0 currentColor;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, MoveActionCommand } from 'shared';
|
||||
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class KeyboardListener extends CommandGenerator {
|
||||
private keysDown: Set<string> = new Set();
|
||||
|
|
@ -13,7 +14,14 @@ export class KeyboardListener extends CommandGenerator {
|
|||
}
|
||||
|
||||
private keyDownListener = (event: KeyboardEvent) => {
|
||||
this.keysDown.add(event.key.toLowerCase());
|
||||
const key = event.key.toLowerCase();
|
||||
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
||||
// held key's auto-repeat doesn't spam leaps.
|
||||
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
}
|
||||
this.keysDown.add(key);
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
|
|
@ -28,11 +36,7 @@ export class KeyboardListener extends CommandGenerator {
|
|||
};
|
||||
|
||||
private generateCommands() {
|
||||
const up = ~~(
|
||||
this.keysDown.has('w') ||
|
||||
this.keysDown.has('arrowup') ||
|
||||
this.keysDown.has(' ')
|
||||
);
|
||||
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||
|
|
@ -42,7 +46,8 @@ export class KeyboardListener extends CommandGenerator {
|
|||
vec2.normalize(movement, movement);
|
||||
}
|
||||
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(movement));
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(movement);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import {
|
|||
MoveActionCommand,
|
||||
last,
|
||||
PrimaryActionCommand,
|
||||
LeapActionCommand,
|
||||
holdDurationToCharge,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
|
|
@ -22,6 +24,7 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
private leapButton: HTMLElement;
|
||||
|
||||
private fireDownAt: number | null = null;
|
||||
|
||||
|
|
@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator {
|
|||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.leapButton = document.createElement('div');
|
||||
this.leapButton.className = 'touch-button leap';
|
||||
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
this.overlay.appendChild(this.fireButton);
|
||||
this.overlay.appendChild(this.leapButton);
|
||||
|
||||
target.addEventListener('touchstart', this.touchStartListener);
|
||||
target.addEventListener('touchmove', this.touchMoveListener);
|
||||
|
|
@ -101,12 +109,17 @@ export class TouchListener extends CommandGenerator {
|
|||
vec2.set(delta, delta.x, -delta.y);
|
||||
if (deltaLength > TouchListener.deadZone) {
|
||||
const direction = vec2.normalize(delta, delta);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(direction));
|
||||
this.sendMove(direction);
|
||||
} else {
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
};
|
||||
|
||||
private sendMove(direction: vec2) {
|
||||
const clientTimeMs = localCharacterPredictor.recordInput(direction);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
|
||||
}
|
||||
|
||||
private touchEndListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
@ -127,7 +140,7 @@ export class TouchListener extends CommandGenerator {
|
|||
} else if (event.touches.length === 0) {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -136,6 +149,12 @@ export class TouchListener extends CommandGenerator {
|
|||
event.stopPropagation();
|
||||
};
|
||||
|
||||
private leapButtonListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
};
|
||||
|
||||
private fireButtonDownListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
this.fireDownAt = performance.now();
|
||||
|
|
@ -170,6 +189,9 @@ export class TouchListener extends CommandGenerator {
|
|||
if (!this.fireButton.parentElement) {
|
||||
this.overlay.appendChild(this.fireButton);
|
||||
}
|
||||
if (!this.leapButton.parentElement) {
|
||||
this.overlay.appendChild(this.leapButton);
|
||||
}
|
||||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (character) {
|
||||
|
|
@ -186,7 +208,9 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
this.fireButton.parentElement?.removeChild(this.fireButton);
|
||||
this.leapButton.parentElement?.removeChild(this.leapButton);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,19 @@ export abstract class FeedbackHud {
|
|||
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
|
||||
}
|
||||
|
||||
public static hitMarker() {
|
||||
public static hitMarker(charge = 0) {
|
||||
const { x, y } = this.focusPoint();
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'hitmarker';
|
||||
marker.className =
|
||||
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
|
||||
marker.style.left = `${x}px`;
|
||||
marker.style.top = `${y}px`;
|
||||
this.addTransient(marker, 250);
|
||||
}
|
||||
|
||||
public static killConfirmed(victimName?: string, streak = 1) {
|
||||
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||
const { killfeed } = this.ensureRoot();
|
||||
const charged = charge >= settings.chargedHitThreshold;
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'kill-entry';
|
||||
|
|
@ -53,13 +55,13 @@ export abstract class FeedbackHud {
|
|||
|
||||
const { x, y } = this.focusPoint();
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'kill-popup';
|
||||
popup.className = 'kill-popup' + (charged ? ' charged' : '');
|
||||
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
this.addTransient(popup, 1200);
|
||||
|
||||
const callout = this.streakName(streak);
|
||||
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
|
||||
if (callout) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'streak-callout';
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
CommandExecutors,
|
||||
Command,
|
||||
settings,
|
||||
InputAcknowledgement,
|
||||
} from 'shared';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { KeyboardListener } from './commands/keyboard-listener';
|
||||
|
|
@ -35,6 +36,7 @@ import { PlanetShape } from './shapes/planet-shape';
|
|||
import { RenderCommand } from './commands/types/render';
|
||||
import { StepCommand } from './commands/types/step';
|
||||
import { serverTimeline } from './helper/server-timeline';
|
||||
import { localCharacterPredictor } from './helper/prediction/local-character-predictor';
|
||||
import { Tutorial } from './tutorial';
|
||||
import { Scoreboard } from './scoreboard';
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ export class Game extends CommandReceiver {
|
|||
private scoreboard = new Scoreboard();
|
||||
private announcementText = document.createElement('h2');
|
||||
private arrows: { [id: number]: HTMLElement } = {};
|
||||
private keystoneArrow?: HTMLElement;
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
|
||||
|
|
@ -76,9 +79,11 @@ export class Game extends CommandReceiver {
|
|||
|
||||
this.socket?.close();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
this.gameObjects = new GameObjectContainer(this);
|
||||
this.overlay.innerHTML = '';
|
||||
this.arrows = {};
|
||||
this.keystoneArrow = undefined;
|
||||
this.isEnding = false;
|
||||
this.lastAnnouncementText = '';
|
||||
this.overlay.appendChild(this.scoreboard.element);
|
||||
|
|
@ -148,6 +153,12 @@ export class Game extends CommandReceiver {
|
|||
this.timeSinceLastAnnouncement = 0;
|
||||
},
|
||||
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
|
||||
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||
localCharacterPredictor.acknowledge(
|
||||
c.clientTimeMs,
|
||||
c.bodyVelocity,
|
||||
c.lastLeapClientTimeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) =>
|
||||
(this.lastOtherPlayerDirections = c),
|
||||
|
|
@ -325,6 +336,71 @@ export class Game extends CommandReceiver {
|
|||
this.handleOtherPlayerDirections(this.lastOtherPlayerDirections);
|
||||
}
|
||||
|
||||
this.handleKeystoneArrow();
|
||||
|
||||
this.announcementText.innerHTML = this.lastAnnouncementText;
|
||||
}
|
||||
|
||||
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
|
||||
// who currently holds it, so the match's focal objective is always findable.
|
||||
private handleKeystoneArrow() {
|
||||
if (!this.renderer) {
|
||||
return;
|
||||
}
|
||||
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
|
||||
if (!keystone) {
|
||||
if (this.keystoneArrow) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.keystoneArrow) {
|
||||
this.keystoneArrow = document.createElement('div');
|
||||
this.overlay.appendChild(this.keystoneArrow);
|
||||
}
|
||||
|
||||
const width = this.renderer.canvasSize.x;
|
||||
const height = this.renderer.canvasSize.y;
|
||||
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
|
||||
const margin = 48;
|
||||
const onScreen =
|
||||
display.x >= margin &&
|
||||
display.x <= width - margin &&
|
||||
display.y >= margin &&
|
||||
display.y <= height - margin;
|
||||
|
||||
const control = Math.abs(keystone.ownership - 0.5);
|
||||
const team =
|
||||
control < settings.planetControlThreshold
|
||||
? 'neutral'
|
||||
: keystone.ownership < 0.5
|
||||
? 'blue'
|
||||
: 'red';
|
||||
this.keystoneArrow.className = 'keystone-arrow ' + team;
|
||||
|
||||
if (onScreen) {
|
||||
this.keystoneArrow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
this.keystoneArrow.style.display = 'block';
|
||||
|
||||
const center = vec2.fromValues(width / 2, height / 2);
|
||||
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
|
||||
const angle = Math.atan2(dir.y, dir.x);
|
||||
|
||||
const aspectRatio = width / height;
|
||||
const directionRatio = dir.x / dir.y;
|
||||
let deltaX: number, deltaY: number;
|
||||
if (aspectRatio < Math.abs(directionRatio)) {
|
||||
deltaX = (width / 2 - margin) * Math.sign(dir.x);
|
||||
deltaY = deltaX / directionRatio;
|
||||
} else {
|
||||
deltaY = (height / 2 - margin) * Math.sign(dir.y);
|
||||
deltaX = deltaY * directionRatio;
|
||||
}
|
||||
|
||||
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
|
||||
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal file
138
frontend/src/scripts/helper/prediction/client-character-world.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CharacterWorld,
|
||||
GroundSurface,
|
||||
Id,
|
||||
PhysicsBody,
|
||||
planetDistance,
|
||||
planetGravity,
|
||||
resolveCircleMovement,
|
||||
} from 'shared';
|
||||
|
||||
// What the predictor needs to know about a planet to collide and be pulled by
|
||||
// it. The client reads this off its PlanetViews.
|
||||
export interface PredictablePlanet {
|
||||
id: Id;
|
||||
vertices: Array<vec2>;
|
||||
center: vec2;
|
||||
radius: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
// A planet collision/gravity surface whose rotation can be advanced during
|
||||
// replay, so its outline turns in lockstep with the body the carry term moves —
|
||||
// exactly as the server steps the planet before the character each tick.
|
||||
class PlanetSurface implements GroundSurface {
|
||||
public readonly canCollide = true;
|
||||
public readonly isGround = true;
|
||||
public readonly id: Id;
|
||||
public center: vec2;
|
||||
public angularVelocity: number;
|
||||
private vertices: Array<vec2>;
|
||||
private radius: number;
|
||||
private rotation = 0;
|
||||
private cos = 1;
|
||||
private sin = 0;
|
||||
|
||||
constructor(planet: PredictablePlanet) {
|
||||
this.id = planet.id;
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
public sync(planet: PredictablePlanet) {
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
private setRotation(rotation: number) {
|
||||
this.rotation = rotation;
|
||||
this.cos = Math.cos(rotation);
|
||||
this.sin = Math.sin(rotation);
|
||||
}
|
||||
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
|
||||
}
|
||||
|
||||
public gravityAt(target: vec2): vec2 {
|
||||
return planetGravity(this.center, this.radius, target);
|
||||
}
|
||||
}
|
||||
|
||||
// The planets-only collision world the local predictor runs against. It holds
|
||||
// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays
|
||||
// valid across frames) and never dispatches collision reactions — damage,
|
||||
// scoring and the like are server-authoritative.
|
||||
export class ClientCharacterWorld implements CharacterWorld {
|
||||
private surfaces = new Map<Id, PlanetSurface>();
|
||||
private ordered: Array<PlanetSurface> = [];
|
||||
|
||||
// Refresh from the current PlanetViews. Far planets contribute zero gravity
|
||||
// (the falloff clamps to 0 past maxGravityDistance) and never collide, so the
|
||||
// whole set can be handed to every query without a range filter. Ordered by
|
||||
// id so the (rare) two-surface contact picks a stable surface.
|
||||
public sync(planets: Array<PredictablePlanet>) {
|
||||
const seen = new Set<Id>();
|
||||
for (const planet of planets) {
|
||||
seen.add(planet.id);
|
||||
const existing = this.surfaces.get(planet.id);
|
||||
if (existing) {
|
||||
existing.sync(planet);
|
||||
} else {
|
||||
this.surfaces.set(planet.id, new PlanetSurface(planet));
|
||||
}
|
||||
}
|
||||
for (const id of [...this.surfaces.keys()]) {
|
||||
if (!seen.has(id)) {
|
||||
this.surfaces.delete(id);
|
||||
}
|
||||
}
|
||||
this.ordered = [...this.surfaces.entries()]
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((e) => e[1]);
|
||||
}
|
||||
|
||||
// Advance every planet's collision frame by one replay substep, so surfaces
|
||||
// and the carried body rotate together. The surfaces are re-synced to the
|
||||
// newest snapshot rotation each frame (see sync), so the replay only ever
|
||||
// steps forward from there.
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
for (const surface of this.ordered) {
|
||||
surface.advance(deltaTimeInSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public surfaceById(id: Id | undefined): GroundSurface | undefined {
|
||||
return id == null ? undefined : this.surfaces.get(id);
|
||||
}
|
||||
|
||||
public idOf(surface: GroundSurface | undefined): Id | undefined {
|
||||
return surface instanceof PlanetSurface ? surface.id : undefined;
|
||||
}
|
||||
|
||||
public groundsNear(): Array<GroundSurface> {
|
||||
return this.ordered;
|
||||
}
|
||||
|
||||
public stepBody(
|
||||
body: PhysicsBody,
|
||||
deltaTimeInSeconds: number,
|
||||
): GroundSurface | undefined {
|
||||
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
|
||||
return hitObject && (hitObject as GroundSurface).isGround
|
||||
? (hitObject as GroundSurface)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal file
47
frontend/src/scripts/helper/prediction/input-history.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
interface InputSample {
|
||||
timeMs: number;
|
||||
direction: vec2;
|
||||
}
|
||||
|
||||
// Keep a little over a second of input — far more than any sane reconciliation
|
||||
// window — so a brief stall (or a backgrounded tab catching up) can still be
|
||||
// replayed, while old samples are pruned to bound memory.
|
||||
const retainMs = 1500;
|
||||
|
||||
// A timeline of the local player's movement directions in client-clock time.
|
||||
// Each generated MoveActionCommand is stamped with the time this hands out, and
|
||||
// the same sample is recorded here so the predictor replays exactly the input
|
||||
// the server will eventually receive. Direction is piecewise-constant: the
|
||||
// active direction at any instant is the most recent sample at or before it.
|
||||
export class InputHistory {
|
||||
private samples: Array<InputSample> = [];
|
||||
|
||||
public record(direction: vec2, timeMs: number): void {
|
||||
this.samples.push({ timeMs, direction: vec2.clone(direction) });
|
||||
|
||||
const cutoff = timeMs - retainMs;
|
||||
while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// The held direction at `timeMs`: the latest sample at or before it, or zero
|
||||
// before any input exists.
|
||||
public directionAt(timeMs: number): vec2 {
|
||||
let direction = vec2.create();
|
||||
for (const sample of this.samples) {
|
||||
if (sample.timeMs <= timeMs) {
|
||||
direction = sample.direction;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return vec2.clone(direction);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
Circle,
|
||||
CharacterMovementState,
|
||||
Id,
|
||||
PhysicsBody,
|
||||
settings,
|
||||
stepCharacterMovement,
|
||||
applyLeapImpulse,
|
||||
tickPlanetDetachment,
|
||||
feetRadius,
|
||||
headRadius,
|
||||
} from 'shared';
|
||||
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
|
||||
import { InputHistory } from './input-history';
|
||||
|
||||
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// Don't replay more than this far back: if the last acknowledged input is older
|
||||
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
|
||||
// instead of grinding through hundreds of steps.
|
||||
const maxReplayMs = 300;
|
||||
|
||||
// Render-side easing of the correction the reconciliation produces each frame,
|
||||
// so a snapshot that disagrees with the prediction is smoothed out instead of
|
||||
// popping. Short, so the local player still feels immediate.
|
||||
const smoothSeconds = 0.06;
|
||||
|
||||
// A correction bigger than this isn't prediction error — it's a respawn,
|
||||
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
|
||||
// the predictor doesn't model. Snap to it rather than gliding across the gap.
|
||||
const snapDistance = 250;
|
||||
|
||||
const makeBody = (center: vec2, radius: number): PhysicsBody => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
velocity: vec2.create(),
|
||||
lastNormal: vec2.fromValues(0, 1),
|
||||
restitution: 0,
|
||||
});
|
||||
|
||||
// Predicts the local player's character so it responds to input immediately,
|
||||
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
|
||||
// Each frame it resets to the latest authoritative snapshot and replays the
|
||||
// player's own un-acknowledged input through the SAME movement simulation the
|
||||
// server runs (shared/stepCharacterMovement), then eases the rendered pose
|
||||
// toward the result. Discrete server-side impulses it can't model show up as a
|
||||
// large correction and snap rather than rubber-band.
|
||||
export class LocalCharacterPredictor {
|
||||
private readonly inputHistory = new InputHistory();
|
||||
private readonly world = new ClientCharacterWorld();
|
||||
|
||||
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||
private lastAckClientTimeMs?: number;
|
||||
// Wall-clock (client) time the latest authoritative snapshot was received. The
|
||||
// replay predicts forward from HERE by the snapshot's age, so the local pose
|
||||
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
|
||||
// the last *acked input* time instead breaks when input is sent only on change
|
||||
// (a held key sends nothing): that time freezes, the window pins to the
|
||||
// maxReplayMs clamp, the replay displacement goes constant, and the pose
|
||||
// stair-steps at the snapshot rate.
|
||||
private authReceiptMs = 0;
|
||||
// Authoritative launch momentum at the last snapshot — seeds each replay so a
|
||||
// leap/slingshot/recoil flight is reproduced and continuously corrected.
|
||||
private authoritativeBodyVelocity = vec2.create();
|
||||
// Wall-clock times the player issued a leap, replayed (with the impulse
|
||||
// applied locally) so the launch is felt immediately, not after a round trip.
|
||||
private leapHistory: Array<number> = [];
|
||||
// clientTimeMs of the last leap the server has folded into the streamed
|
||||
// momentum. Leaps at or before this are already in authoritativeBodyVelocity;
|
||||
// only newer ones are replayed, so a leap is never applied twice.
|
||||
private lastLeapAckMs = -Infinity;
|
||||
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||
private currentStrength = settings.playerMaxStrength;
|
||||
|
||||
// Continuous state carried between replays (the snapshot carries only poses).
|
||||
// The facing direction is NOT carried — it is re-derived from the pose each
|
||||
// frame (see directionFromPose / simulate); only the latched planet and the
|
||||
// time-since-surface persist.
|
||||
private carriedPlanetId?: Id;
|
||||
private carriedSecondsSinceSurface = 1;
|
||||
|
||||
// The eased, rendered pose handed to the view.
|
||||
private renderHead = new Circle(vec2.create(), headRadius);
|
||||
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
||||
private renderRightFoot = new Circle(vec2.create(), feetRadius);
|
||||
private hasRender = false;
|
||||
|
||||
public get head(): Circle {
|
||||
return this.renderHead;
|
||||
}
|
||||
public get leftFoot(): Circle {
|
||||
return this.renderLeftFoot;
|
||||
}
|
||||
public get rightFoot(): Circle {
|
||||
return this.renderRightFoot;
|
||||
}
|
||||
|
||||
// Stamp a movement command and record it for replay. Returns the wall-clock
|
||||
// time the command should carry so the server can echo it back.
|
||||
public recordInput(direction: vec2): number {
|
||||
const timeMs = Math.round(performance.now());
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
||||
public acknowledge(
|
||||
clientTimeMs: number,
|
||||
bodyVelocity: vec2,
|
||||
lastLeapClientTimeMs: number,
|
||||
): void {
|
||||
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||
// leap boundary always adopt the latest authoritative values.
|
||||
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
|
||||
this.lastAckClientTimeMs = clientTimeMs;
|
||||
}
|
||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||
this.lastLeapAckMs = lastLeapClientTimeMs;
|
||||
}
|
||||
|
||||
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
this.authReceiptMs = Math.round(performance.now());
|
||||
}
|
||||
|
||||
// The player pressed leap; remember when, so the replay applies the same
|
||||
// impulse the server will. Recorded regardless of whether the server accepts
|
||||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||
// authoritative momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(performance.now());
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
this.leapHistory.shift();
|
||||
}
|
||||
return timeMs;
|
||||
}
|
||||
|
||||
public setStrength(strength: number): void {
|
||||
this.currentStrength = strength;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.inputHistory.reset();
|
||||
this.leapHistory = [];
|
||||
this.lastLeapAckMs = -Infinity;
|
||||
this.authoritative = undefined;
|
||||
this.lastAckClientTimeMs = undefined;
|
||||
this.authReceiptMs = 0;
|
||||
vec2.zero(this.authoritativeBodyVelocity);
|
||||
this.currentStrength = settings.playerMaxStrength;
|
||||
this.carriedPlanetId = undefined;
|
||||
this.carriedSecondsSinceSurface = 1;
|
||||
this.hasRender = false;
|
||||
}
|
||||
|
||||
public get canPredict(): boolean {
|
||||
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
|
||||
}
|
||||
|
||||
// During spawn-in and death the server freezes walking and only scales the
|
||||
// body (CharacterPhysical.step returns early), so its head radius is below
|
||||
// nominal. Predicting then would walk the body off a position the server is
|
||||
// holding still — let interpolation show the animation instead.
|
||||
private get isAnimatingInOrOut(): boolean {
|
||||
return (
|
||||
this.authoritative !== undefined &&
|
||||
this.authoritative.head.radius < headRadius - 0.5
|
||||
);
|
||||
}
|
||||
|
||||
// Run one frame of prediction. Returns true if it produced a rendered pose the
|
||||
// caller should use (otherwise fall back to interpolation). `planets` is the
|
||||
// current collision world; `frameSeconds` is the render delta.
|
||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||
if (!this.canPredict || this.isAnimatingInOrOut) {
|
||||
// Resume from the authoritative pose with a snap rather than gliding from
|
||||
// a stale rendered one.
|
||||
this.hasRender = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.world.sync(planets);
|
||||
const predicted = this.simulate();
|
||||
|
||||
if (!this.hasRender) {
|
||||
this.snapRenderTo(predicted);
|
||||
this.hasRender = true;
|
||||
} else {
|
||||
this.easeRenderTo(predicted, frameSeconds);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The body's facing angle is encoded in the pose: each part is sprung toward
|
||||
// center + R(direction)*offset and the head's offset points +y, so
|
||||
// direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot
|
||||
// each frame (rather than carrying the previous frame's evolved value onto
|
||||
// this past pose, re-evolved by a variable substep count) keeps the posture
|
||||
// seed a pure function of the snapshot — carrying it fed a frame-rate-dependent
|
||||
// loop that wobbled the rendered limbs.
|
||||
private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number {
|
||||
const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3;
|
||||
const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3;
|
||||
return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2;
|
||||
}
|
||||
|
||||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const now = Math.round(performance.now());
|
||||
// Predict forward from the latest snapshot by its age, clamped so a stall
|
||||
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
|
||||
// steps. This advances with wall-clock time even while a held key sends no
|
||||
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
|
||||
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
|
||||
const windowMs = Math.max(0, now - startMs);
|
||||
const steps = Math.floor(windowMs / stepMs);
|
||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||
|
||||
const state: CharacterMovementState = {
|
||||
head: makeBody(auth.head.center, auth.head.radius),
|
||||
leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius),
|
||||
rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius),
|
||||
direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot),
|
||||
currentPlanet: this.world.surfaceById(this.carriedPlanetId),
|
||||
secondsSinceOnSurface: this.carriedSecondsSinceSurface,
|
||||
// Leaps before the replay window are already baked into this; leaps inside
|
||||
// the window are re-applied below, so neither is double-counted.
|
||||
bodyVelocity: vec2.clone(this.authoritativeBodyVelocity),
|
||||
};
|
||||
|
||||
// The planet collision frames were synced (in update(), just before this)
|
||||
// to the NEWEST snapshot's rotation — the same instant the authoritative
|
||||
// body pose is from — so the body and the surface start the replay at the
|
||||
// same phase. The loop below advances the surfaces FORWARD in lockstep with
|
||||
// the body's carry from that shared phase, so no rewind is needed (and the
|
||||
// persistent surfaces are re-synced next frame, so this never accumulates).
|
||||
|
||||
const cooldownMs = settings.leapCooldownSeconds * 1000;
|
||||
// Mirror the server: each accepted leap spends leapStrengthCost, so a burst
|
||||
// of leaps in one replay window is gated by the running strength rather than
|
||||
// a single up-front affordability check.
|
||||
let availableStrength = this.currentStrength;
|
||||
let lastLeapMs = -Infinity;
|
||||
|
||||
let t = startMs;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const input = this.inputHistory.directionAt(t);
|
||||
tickPlanetDetachment(state, stepSeconds);
|
||||
stepCharacterMovement(state, this.world, input, stepSeconds);
|
||||
this.world.advance(stepSeconds);
|
||||
|
||||
// A leap issued during this step launches now (the next step injects the
|
||||
// momentum), gated like the server: on a surface, off cooldown, with
|
||||
// strength. applyLeapImpulse is a no-op off-surface.
|
||||
for (const leapMs of this.leapHistory) {
|
||||
if (
|
||||
leapMs >= t &&
|
||||
leapMs < t + stepMs &&
|
||||
// Only leaps the server hasn't yet folded into the seed, so a leap
|
||||
// is never both seeded and replayed.
|
||||
leapMs > this.lastLeapAckMs &&
|
||||
state.currentPlanet &&
|
||||
leapMs - lastLeapMs >= cooldownMs &&
|
||||
availableStrength >= settings.leapStrengthCost
|
||||
) {
|
||||
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
|
||||
availableStrength -= settings.leapStrengthCost;
|
||||
lastLeapMs = leapMs;
|
||||
}
|
||||
}
|
||||
|
||||
t += stepMs;
|
||||
}
|
||||
|
||||
// Final sub-tick of the leftover (< stepMs) so the predicted pose is a
|
||||
// continuous function of the window length rather than advancing in 5 ms
|
||||
// quanta — the floor() above would otherwise surface that as a per-frame
|
||||
// wobble on top of the ~16.7 ms render cadence.
|
||||
if (remainderSeconds > 0) {
|
||||
tickPlanetDetachment(state, remainderSeconds);
|
||||
stepCharacterMovement(
|
||||
state,
|
||||
this.world,
|
||||
this.inputHistory.directionAt(now),
|
||||
remainderSeconds,
|
||||
);
|
||||
this.world.advance(remainderSeconds);
|
||||
}
|
||||
|
||||
this.carriedPlanetId = this.world.idOf(state.currentPlanet);
|
||||
this.carriedSecondsSinceSurface = state.secondsSinceOnSurface;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private snapRenderTo(state: CharacterMovementState): void {
|
||||
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
|
||||
this.renderLeftFoot = new Circle(
|
||||
vec2.clone(state.leftFoot.center),
|
||||
state.leftFoot.radius,
|
||||
);
|
||||
this.renderRightFoot = new Circle(
|
||||
vec2.clone(state.rightFoot.center),
|
||||
state.rightFoot.radius,
|
||||
);
|
||||
}
|
||||
|
||||
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
||||
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
|
||||
this.easePart(this.renderHead, state.head, q);
|
||||
this.easePart(this.renderLeftFoot, state.leftFoot, q);
|
||||
this.easePart(this.renderRightFoot, state.rightFoot, q);
|
||||
}
|
||||
|
||||
private easePart(render: Circle, target: PhysicsBody, q: number): void {
|
||||
if (vec2.distance(render.center, target.center) > snapDistance) {
|
||||
vec2.copy(render.center, target.center);
|
||||
} else {
|
||||
vec2.lerp(render.center, render.center, target.center, q);
|
||||
}
|
||||
render.radius = target.radius;
|
||||
}
|
||||
}
|
||||
|
||||
export const localCharacterPredictor = new LocalCharacterPredictor();
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Circle,
|
||||
Command,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
|
|
@ -9,11 +10,15 @@ import {
|
|||
Id,
|
||||
PropertyUpdatesForObjects,
|
||||
RemoteCallsForObjects,
|
||||
settings,
|
||||
UpdatePropertyCommand,
|
||||
} from 'shared';
|
||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||
import { StepCommand } from '../commands/types/step';
|
||||
import { Game } from '../game';
|
||||
import { serverTimeline } from '../helper/server-timeline';
|
||||
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
import { Camera } from './types/camera';
|
||||
import { CharacterView } from './types/character-view';
|
||||
import { PlanetView } from './types/planet-view';
|
||||
|
|
@ -28,6 +33,9 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
this.player = c.character as CharacterView;
|
||||
this.player.isMainCharacter = true;
|
||||
this.addObject(this.player);
|
||||
// Fresh character (first spawn or respawn at a far planet): drop any
|
||||
// prediction state so it snaps to the new body instead of gliding across.
|
||||
localCharacterPredictor.reset();
|
||||
},
|
||||
|
||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||
|
|
@ -37,6 +45,18 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
this.defaultCommandExecutor(c);
|
||||
|
||||
if (this.player) {
|
||||
// Override the interpolated pose of the local player with the predicted
|
||||
// one so it responds to input immediately. Remote objects keep
|
||||
// interpolating. A large correction (respawn / death) snaps inside the
|
||||
// predictor rather than rubber-banding.
|
||||
localCharacterPredictor.setStrength(
|
||||
this.player.strengthFraction * settings.playerMaxStrength,
|
||||
);
|
||||
if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
|
||||
this.player.head = localCharacterPredictor.head;
|
||||
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
||||
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||
}
|
||||
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
|
||||
}
|
||||
},
|
||||
|
|
@ -48,9 +68,12 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
|
||||
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
||||
serverTimeline.onSnapshot(c.timestamp);
|
||||
c.updates.forEach((u) =>
|
||||
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)),
|
||||
);
|
||||
c.updates.forEach((u) => {
|
||||
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
|
||||
if (this.player && u.id === this.player.id) {
|
||||
this.feedPredictor(u.updates);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
||||
|
|
@ -76,6 +99,34 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
this.camera.handleCommand(c);
|
||||
}
|
||||
|
||||
// Hand the local player's raw authoritative pose to the predictor (the
|
||||
// interpolated pose would already be ~100 ms stale). The three body parts
|
||||
// arrive together in one snapshot.
|
||||
private feedPredictor(updates: Array<UpdatePropertyCommand>) {
|
||||
let head: Circle | undefined;
|
||||
let leftFoot: Circle | undefined;
|
||||
let rightFoot: Circle | undefined;
|
||||
for (const u of updates) {
|
||||
if (u.propertyKey === 'head') head = u.propertyValue as Circle;
|
||||
else if (u.propertyKey === 'leftFoot') leftFoot = u.propertyValue as Circle;
|
||||
else if (u.propertyKey === 'rightFoot') rightFoot = u.propertyValue as Circle;
|
||||
}
|
||||
if (head && leftFoot && rightFoot) {
|
||||
localCharacterPredictor.setAuthoritative(head, leftFoot, rightFoot);
|
||||
}
|
||||
}
|
||||
|
||||
private predictablePlanets(): Array<PredictablePlanet> {
|
||||
return this.planets.map((p) => ({
|
||||
id: p.id,
|
||||
vertices: p.vertices,
|
||||
center: p.center,
|
||||
radius: p.radius,
|
||||
rotation: p.predictionRotation,
|
||||
rotationSpeed: p.predictionRotationSpeed,
|
||||
}));
|
||||
}
|
||||
|
||||
private addObject(object: GameObject) {
|
||||
this.objects.set(object.id, object);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,21 +176,32 @@ export class CharacterView extends CharacterBase {
|
|||
}
|
||||
}
|
||||
|
||||
public onHitConfirmed() {
|
||||
public onHitConfirmed(charge = 0) {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 0.4, 1.7);
|
||||
FeedbackHud.hitMarker();
|
||||
// A charged hit lands lower and harder than a panic tap.
|
||||
SoundHandler.play(Sounds.click, mix(0.4, 0.75, charge), mix(1.7, 1.05, charge));
|
||||
if (charge >= settings.chargedHitThreshold) {
|
||||
VibrationHandler.vibrate(25);
|
||||
}
|
||||
FeedbackHud.hitMarker(charge);
|
||||
}
|
||||
|
||||
public onKillConfirmed(victimName?: string, streak = 1) {
|
||||
public onKillConfirmed(victimName?: string, streak = 1, charge = 0) {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 1, 0.7);
|
||||
VibrationHandler.vibrate(60);
|
||||
FeedbackHud.killConfirmed(victimName, streak);
|
||||
SoundHandler.play(Sounds.click, 1, mix(0.7, 0.5, charge));
|
||||
VibrationHandler.vibrate(mix(60, 110, charge));
|
||||
FeedbackHud.killConfirmed(victimName, streak, charge);
|
||||
}
|
||||
|
||||
public onLeap() {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.shoot, 0.3, 1.5);
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
|
|
|
|||
|
|
@ -56,17 +56,39 @@ export class PlanetView extends PlanetBase {
|
|||
[UpdatePropertyCommand.type]: this.updateProperty.bind(this),
|
||||
};
|
||||
|
||||
constructor(id: Id, vertices: Array<vec2>, ownership: number) {
|
||||
super(id, vertices);
|
||||
constructor(id: Id, vertices: Array<vec2>, ownership = 0.5, isKeystone = false) {
|
||||
super(id, vertices, ownership, isKeystone);
|
||||
this.shape = new PlanetShape(vertices, ownership);
|
||||
this.shape.randomOffset = Random.getRandom();
|
||||
|
||||
this.ownershipProgress = document.createElement('div');
|
||||
this.ownershipProgress.className = 'ownership';
|
||||
this.ownershipProgress.className = 'ownership' + (isKeystone ? ' keystone' : '');
|
||||
}
|
||||
|
||||
public setContested(contested: boolean) {
|
||||
this.ownershipProgress.classList.toggle('contested', contested);
|
||||
}
|
||||
|
||||
private renderedRotation = 0;
|
||||
private rotationSpeed = 0;
|
||||
// Newest streamed rotation VALUE — the server-current angle at the latest
|
||||
// snapshot — as opposed to renderedRotation, which the interpolator holds
|
||||
// ~interpolationDelaySeconds in the PAST for drawing. The predictor seeds the
|
||||
// local body from the same snapshot's pose, so it must collide against the
|
||||
// planet at THIS (newest) phase and advance forward from it; using the drawn
|
||||
// lagged angle biases the body off the surface by omega*delay*radius and
|
||||
// wobbles it whenever the spin or the interpolator's rate cursor varies.
|
||||
private latestRotation = 0;
|
||||
public get predictionRotation(): number {
|
||||
return this.latestRotation;
|
||||
}
|
||||
public get predictionRotationSpeed(): number {
|
||||
return this.rotationSpeed;
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
|
||||
this.renderedRotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
|
||||
this.shape.rotation = this.renderedRotation;
|
||||
this.shape.colorMixQ = this.ownership;
|
||||
|
||||
if (this.flareIntensity > 0) {
|
||||
|
|
@ -129,6 +151,8 @@ export class PlanetView extends PlanetBase {
|
|||
}: UpdatePropertyCommand): void {
|
||||
if (propertyKey === 'rotation') {
|
||||
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
|
||||
this.latestRotation = propertyValue;
|
||||
this.rotationSpeed = rateOfChange;
|
||||
} else {
|
||||
this.ownership = propertyValue;
|
||||
}
|
||||
|
|
@ -170,7 +194,12 @@ export class PlanetView extends PlanetBase {
|
|||
|
||||
private getGradient(): string {
|
||||
const sideBlue = this.ownership < 0.5;
|
||||
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100;
|
||||
// Keep the ring neutral through the same dead-band that gates scoring
|
||||
// (settings.planetControlThreshold), so "the ring fills" and "this planet
|
||||
// pays my team" happen together rather than disagreeing.
|
||||
const control = Math.abs(this.ownership - 0.5);
|
||||
const t = settings.planetControlThreshold;
|
||||
const sidePercent = control <= t ? 0 : ((control - t) / (0.5 - t)) * 100;
|
||||
return sideBlue
|
||||
? `conic-gradient(
|
||||
var(--bright-blue) ${sidePercent}%,
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
|
||||
settings.redPlanetColor,
|
||||
)}, planetColorMixQ[j]);
|
||||
settings.redPlanetColor,
|
||||
)}, planetColorMixQ[j]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,11 +129,32 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
public randomOffset = 0;
|
||||
public rotation = 0;
|
||||
|
||||
// Circle about the rotation centre (the vertex centroid, which is what the
|
||||
// shader spins around — see planetMinDistance above). The vertices never
|
||||
// change after construction, so cache it once.
|
||||
private readonly cullCenter: vec2;
|
||||
private readonly cullRadius: number;
|
||||
|
||||
constructor(
|
||||
public vertices: Array<vec2>,
|
||||
public colorMixQ: number,
|
||||
) {
|
||||
super(vertices);
|
||||
|
||||
this.cullCenter = vertices.reduce(
|
||||
(sum, v) => vec2.add(sum, sum, v),
|
||||
vec2.create(),
|
||||
);
|
||||
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
|
||||
|
||||
this.cullRadius = vertices.reduce(
|
||||
(max, v) => Math.max(max, vec2.distance(this.cullCenter, v)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
public minDistance(target: vec2): number {
|
||||
return vec2.distance(target, this.cullCenter) - this.cullRadius;
|
||||
}
|
||||
|
||||
protected getObjectToSerialize(transform2d: mat2d, _: number): any {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@ import {
|
|||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
Id,
|
||||
LeapActionCommand,
|
||||
MoveActionCommand,
|
||||
PrimaryActionCommand,
|
||||
} from 'shared';
|
||||
import { GameObjectContainer } from './objects/game-object-container';
|
||||
import { PlanetView } from './objects/types/planet-view';
|
||||
|
||||
type StageTrigger = 'move' | 'shoot' | 'capture';
|
||||
type StageTrigger = 'move' | 'shoot' | 'leap' | 'capture';
|
||||
|
||||
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
|
||||
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
|
||||
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
|
||||
{ hint: 'Space / leap button to launch off a planet', clearsOn: 'leap' },
|
||||
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
|
||||
];
|
||||
|
||||
|
|
@ -42,6 +44,11 @@ export class Tutorial extends CommandReceiver {
|
|||
this.advance();
|
||||
}
|
||||
},
|
||||
[LeapActionCommand.type]: () => {
|
||||
if (this.clearsOn() === 'leap') {
|
||||
this.advance();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
constructor(overlay: HTMLElement) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue