reactive controls

This commit is contained in:
Andras Schmelczer 2026-09-15 20:52:20 +01:00
commit d3750fde3c
4 changed files with 252 additions and 37 deletions

View file

@ -208,7 +208,9 @@ body {
mask-image: url('../static/mask.svg');
&.contested {
animation: contested-pulse 0.7s ease-in-out infinite;
box-shadow:
inset 0 0 0 2px rgba(255, 255, 255, 0.7),
inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
}
&.keystone {
@ -320,6 +322,15 @@ body {
display: inline-block;
}
&[hidden] {
display: none;
}
&[aria-disabled='true'] {
opacity: 0.3;
cursor: default;
}
&.fire {
right: $medium-padding;
@ -757,18 +768,6 @@ body {
}
}
@keyframes contested-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.charge-ring {
position: fixed;
@include square(56px);

View file

@ -4,6 +4,7 @@ import type { Command } from 'shared';
import type { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { centeredTransform } from '../helper/centered-transform';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { InputGenerator } from './input-generator';
const deadZone = 8;
@ -196,15 +197,23 @@ export class TouchListener extends InputGenerator {
event.stopPropagation();
}
private get isAlive(): boolean {
return (this.game.gameObjects.localPlayer?.health ?? 0) > 0;
}
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
if (!this.isAlive || !localCharacterPredictor.canLeap) {
return;
}
this.sendLeap();
this.leapButton.hidden = true;
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
const touch = event.changedTouches[0];
if (this.fireTouchId !== null || !touch) {
if (!this.isAlive || this.fireTouchId !== null || !touch) {
return;
}
this.fireTouchId = touch.identifier;
@ -255,7 +264,7 @@ export class TouchListener extends InputGenerator {
this.cancelFire();
const character = this.game.gameObjects.localPlayer;
if (!character) {
if (!character || character.health <= 0) {
return;
}
@ -287,6 +296,11 @@ export class TouchListener extends InputGenerator {
}
const character = this.game.gameObjects.localPlayer;
this.fireButton.setAttribute('aria-disabled', String(!this.isAlive));
this.leapButton.hidden = !this.isAlive || !localCharacterPredictor.canLeap;
if (!this.isAlive && this.fireTouchId !== null) {
this.cancelFire();
}
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360

View file

@ -55,7 +55,7 @@ export class Game extends CommandReceiver {
private socket!: Socket;
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
private tutorial?: Tutorial;
private resolveStarted!: () => void;
private isBetweenGames = false;
private isActive = true;
@ -92,7 +92,7 @@ export class Game extends CommandReceiver {
),
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
[GameStartCommand.name]: () => this.initialize(),
[GameStartCommand.name]: () => this.initialize(true),
};
constructor(
@ -106,7 +106,7 @@ export class Game extends CommandReceiver {
const onInput = (c: Command) => {
this.socketReceiver.queue(c);
this.tutorial.handleCommand(c);
this.tutorial?.handleCommand(c);
};
this.keyboardListener = new KeyboardListener(onInput);
this.mouseListener = new MouseListener(this.canvas, this, onInput);
@ -117,7 +117,7 @@ export class Game extends CommandReceiver {
this.gameObjects.handleCommand(c);
}
private initialize() {
private initialize(isNewGame = false) {
this.isBetweenGames = true;
this.socket?.close();
@ -131,14 +131,15 @@ export class Game extends CommandReceiver {
this.keystoneArrow = undefined;
this.connectionBanner = undefined;
this.lastMinimap = undefined;
this.lastAnnouncementText = '';
this.lastAnnouncementText = isNewGame ? 'New game!' : '';
this.timeSinceLastAnnouncement = 0;
this.announcementText.innerText = '';
this.overlay.append(
this.scoreboard.element,
this.minimap.element,
this.announcementText,
);
this.tutorial = new Tutorial(this.overlay);
this.tutorial = isNewGame ? undefined : new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000,
@ -354,7 +355,7 @@ export class Game extends CommandReceiver {
this.gameObjects.step(deltaTime);
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
this.touchListener.update();
this.tutorial.step(this.gameObjects);
this.tutorial?.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands();
return this.isActive;

View file

@ -28,6 +28,11 @@ const maxReplayMs = 400;
const correctionSeconds = 0.08;
const snapDistance = 1500;
interface PredictedMovementState extends CharacterMovementState {
availableStrength: number;
leapCooldownRemaining: number;
}
interface ReplaySnapshot {
pose: { head: Circle; leftFoot: Circle; rightFoot: Circle };
movement: CharacterMovementSnapshot;
@ -35,7 +40,7 @@ interface ReplaySnapshot {
leapAckMs: number;
strength: number;
planets: Array<PlanetSnapshot>;
horizonState?: CharacterMovementState;
horizonState?: PredictedMovementState;
}
const upwards = vec2.fromValues(0, 1);
@ -59,6 +64,7 @@ export class LocalCharacterPredictor {
private leapHistory: Array<number> = [];
private lastLeapAckMs = -Infinity;
private currentStrength = settings.playerMaxStrength;
private predicted?: PredictedMovementState;
private alive = true;
private previousSnapshot?: ReplaySnapshot;
@ -79,6 +85,17 @@ export class LocalCharacterPredictor {
return this.renderRightFoot;
}
public get canLeap(): boolean {
return (
this.enabled &&
this.alive &&
!this.isAnimatingInOrOut &&
!!this.predicted?.currentPlanet &&
this.predicted.leapCooldownRemaining <= 0 &&
this.predicted.availableStrength >= settings.leapStrengthCost
);
}
public recordInput(direction: vec2): number {
const timeMs = clientTimeMs();
this.inputHistory.record(direction, timeMs);
@ -103,6 +120,7 @@ export class LocalCharacterPredictor {
public recordLeap(): number {
const timeMs = clientTimeMs();
this.leapHistory.push(timeMs);
this.predicted = undefined;
const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
this.leapHistory.shift();
@ -126,6 +144,7 @@ export class LocalCharacterPredictor {
this.replayAnchorMs = undefined;
this.movement = undefined;
this.currentStrength = settings.playerMaxStrength;
this.predicted = undefined;
this.alive = true;
this.previousSnapshot = undefined;
this.lastUpdateMs = undefined;
@ -149,6 +168,7 @@ export class LocalCharacterPredictor {
public update(planets: Array<PlanetView>): boolean {
if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) {
this.predicted = undefined;
this.previousSnapshot = undefined;
this.lastUpdateMs = undefined;
vec2.zero(this.correction);
@ -182,6 +202,7 @@ export class LocalCharacterPredictor {
),
};
const predicted = this.simulate(snapshot);
this.predicted = predicted;
if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
// Compare both snapshots at the same instant: smooth only the correction,
@ -210,7 +231,7 @@ export class LocalCharacterPredictor {
return true;
}
private simulate(snapshot: ReplaySnapshot): CharacterMovementState {
private simulate(snapshot: ReplaySnapshot): PredictedMovementState {
if (snapshot.horizonState) {
return snapshot.horizonState;
}
@ -222,7 +243,7 @@ export class LocalCharacterPredictor {
const steps = Math.floor(windowMs / stepMs);
const partialStep = (windowMs - steps * stepMs) / stepMs;
const state: CharacterMovementState = {
const state: PredictedMovementState = {
head: makeBody(auth.head.center, auth.head.radius, upwards),
leftFoot: makeBody(
auth.leftFoot.center,
@ -238,11 +259,10 @@ export class LocalCharacterPredictor {
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
secondsSinceOnSurface: movement.secondsSinceOnSurface,
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
availableStrength: snapshot.strength,
leapCooldownRemaining: movement.leapCooldownRemaining ?? 0,
};
let availableStrength = snapshot.strength;
let cooldown = movement.leapCooldownRemaining ?? 0;
let t = startMs;
for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
const fraction = i < steps ? 1 : partialStep;
@ -258,12 +278,12 @@ export class LocalCharacterPredictor {
leapMs < t + stepMs * fraction &&
leapMs > snapshot.leapAckMs &&
state.currentPlanet &&
cooldown <= 0 &&
availableStrength >= settings.leapStrengthCost
state.leapCooldownRemaining <= 0 &&
state.availableStrength >= settings.leapStrengthCost
) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
availableStrength -= settings.leapStrengthCost;
cooldown = settings.leapCooldownSeconds;
state.availableStrength -= settings.leapStrengthCost;
state.leapCooldownRemaining = settings.leapCooldownSeconds;
}
}
this.world.advance(stepSeconds);
@ -274,10 +294,14 @@ export class LocalCharacterPredictor {
this.inputHistory.directionAt(t),
stepSeconds,
);
cooldown = Math.max(0, cooldown - stepSeconds);
availableStrength = Math.min(
state.leapCooldownRemaining = Math.max(
0,
state.leapCooldownRemaining - stepSeconds,
);
state.availableStrength = Math.min(
settings.playerMaxStrength,
availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds,
state.availableStrength +
settings.playerStrengthRegenerationPerSeconds * stepSeconds,
);
if (from) {
[state.head, state.leftFoot, state.rightFoot].forEach((body, index) =>