reactive controls
This commit is contained in:
parent
b100a75f7d
commit
d3750fde3c
6 changed files with 252 additions and 37 deletions
|
|
@ -208,7 +208,9 @@ body {
|
||||||
mask-image: url('../static/mask.svg');
|
mask-image: url('../static/mask.svg');
|
||||||
|
|
||||||
&.contested {
|
&.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 {
|
&.keystone {
|
||||||
|
|
@ -320,6 +322,15 @@ body {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-disabled='true'] {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
&.fire {
|
&.fire {
|
||||||
right: $medium-padding;
|
right: $medium-padding;
|
||||||
|
|
||||||
|
|
@ -757,18 +768,6 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes contested-pulse {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.charge-ring {
|
.charge-ring {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@include square(56px);
|
@include square(56px);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { Command } from 'shared';
|
||||||
import type { Game } from '../game';
|
import type { Game } from '../game';
|
||||||
import { ChargeIndicator } from '../charge-indicator';
|
import { ChargeIndicator } from '../charge-indicator';
|
||||||
import { centeredTransform } from '../helper/centered-transform';
|
import { centeredTransform } from '../helper/centered-transform';
|
||||||
|
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||||
import { InputGenerator } from './input-generator';
|
import { InputGenerator } from './input-generator';
|
||||||
|
|
||||||
const deadZone = 8;
|
const deadZone = 8;
|
||||||
|
|
@ -196,15 +197,23 @@ export class TouchListener extends InputGenerator {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get isAlive(): boolean {
|
||||||
|
return (this.game.gameObjects.localPlayer?.health ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private leapButtonListener = (event: TouchEvent) => {
|
private leapButtonListener = (event: TouchEvent) => {
|
||||||
this.swallowTouch(event);
|
this.swallowTouch(event);
|
||||||
|
if (!this.isAlive || !localCharacterPredictor.canLeap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.sendLeap();
|
this.sendLeap();
|
||||||
|
this.leapButton.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
private fireButtonDownListener = (event: TouchEvent) => {
|
private fireButtonDownListener = (event: TouchEvent) => {
|
||||||
this.swallowTouch(event);
|
this.swallowTouch(event);
|
||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
if (this.fireTouchId !== null || !touch) {
|
if (!this.isAlive || this.fireTouchId !== null || !touch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.fireTouchId = touch.identifier;
|
this.fireTouchId = touch.identifier;
|
||||||
|
|
@ -255,7 +264,7 @@ export class TouchListener extends InputGenerator {
|
||||||
this.cancelFire();
|
this.cancelFire();
|
||||||
|
|
||||||
const character = this.game.gameObjects.localPlayer;
|
const character = this.game.gameObjects.localPlayer;
|
||||||
if (!character) {
|
if (!character || character.health <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +296,11 @@ export class TouchListener extends InputGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
const character = this.game.gameObjects.localPlayer;
|
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) {
|
if (character) {
|
||||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||||
character.strengthFraction * 360
|
character.strengthFraction * 360
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export class Game extends CommandReceiver {
|
||||||
|
|
||||||
private socket!: Socket;
|
private socket!: Socket;
|
||||||
private socketReceiver!: CommandSocket;
|
private socketReceiver!: CommandSocket;
|
||||||
private tutorial!: Tutorial;
|
private tutorial?: Tutorial;
|
||||||
private resolveStarted!: () => void;
|
private resolveStarted!: () => void;
|
||||||
private isBetweenGames = false;
|
private isBetweenGames = false;
|
||||||
private isActive = true;
|
private isActive = true;
|
||||||
|
|
@ -92,7 +92,7 @@ export class Game extends CommandReceiver {
|
||||||
),
|
),
|
||||||
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
|
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
|
||||||
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||||
[GameStartCommand.name]: () => this.initialize(),
|
[GameStartCommand.name]: () => this.initialize(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -106,7 +106,7 @@ export class Game extends CommandReceiver {
|
||||||
|
|
||||||
const onInput = (c: Command) => {
|
const onInput = (c: Command) => {
|
||||||
this.socketReceiver.queue(c);
|
this.socketReceiver.queue(c);
|
||||||
this.tutorial.handleCommand(c);
|
this.tutorial?.handleCommand(c);
|
||||||
};
|
};
|
||||||
this.keyboardListener = new KeyboardListener(onInput);
|
this.keyboardListener = new KeyboardListener(onInput);
|
||||||
this.mouseListener = new MouseListener(this.canvas, this, onInput);
|
this.mouseListener = new MouseListener(this.canvas, this, onInput);
|
||||||
|
|
@ -117,7 +117,7 @@ export class Game extends CommandReceiver {
|
||||||
this.gameObjects.handleCommand(c);
|
this.gameObjects.handleCommand(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initialize() {
|
private initialize(isNewGame = false) {
|
||||||
this.isBetweenGames = true;
|
this.isBetweenGames = true;
|
||||||
|
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
|
|
@ -131,14 +131,15 @@ export class Game extends CommandReceiver {
|
||||||
this.keystoneArrow = undefined;
|
this.keystoneArrow = undefined;
|
||||||
this.connectionBanner = undefined;
|
this.connectionBanner = undefined;
|
||||||
this.lastMinimap = undefined;
|
this.lastMinimap = undefined;
|
||||||
this.lastAnnouncementText = '';
|
this.lastAnnouncementText = isNewGame ? 'New game!' : '';
|
||||||
|
this.timeSinceLastAnnouncement = 0;
|
||||||
this.announcementText.innerText = '';
|
this.announcementText.innerText = '';
|
||||||
this.overlay.append(
|
this.overlay.append(
|
||||||
this.scoreboard.element,
|
this.scoreboard.element,
|
||||||
this.minimap.element,
|
this.minimap.element,
|
||||||
this.announcementText,
|
this.announcementText,
|
||||||
);
|
);
|
||||||
this.tutorial = new Tutorial(this.overlay);
|
this.tutorial = isNewGame ? undefined : new Tutorial(this.overlay);
|
||||||
|
|
||||||
this.socket = io(this.playerDecision.server, {
|
this.socket = io(this.playerDecision.server, {
|
||||||
reconnectionDelayMax: 10000,
|
reconnectionDelayMax: 10000,
|
||||||
|
|
@ -354,7 +355,7 @@ export class Game extends CommandReceiver {
|
||||||
this.gameObjects.step(deltaTime);
|
this.gameObjects.step(deltaTime);
|
||||||
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
|
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
|
||||||
this.touchListener.update();
|
this.touchListener.update();
|
||||||
this.tutorial.step(this.gameObjects);
|
this.tutorial?.step(this.gameObjects);
|
||||||
this.socketReceiver.sendQueuedCommands();
|
this.socketReceiver.sendQueuedCommands();
|
||||||
|
|
||||||
return this.isActive;
|
return this.isActive;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ const maxReplayMs = 400;
|
||||||
const correctionSeconds = 0.08;
|
const correctionSeconds = 0.08;
|
||||||
const snapDistance = 1500;
|
const snapDistance = 1500;
|
||||||
|
|
||||||
|
interface PredictedMovementState extends CharacterMovementState {
|
||||||
|
availableStrength: number;
|
||||||
|
leapCooldownRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReplaySnapshot {
|
interface ReplaySnapshot {
|
||||||
pose: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
pose: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||||
movement: CharacterMovementSnapshot;
|
movement: CharacterMovementSnapshot;
|
||||||
|
|
@ -35,7 +40,7 @@ interface ReplaySnapshot {
|
||||||
leapAckMs: number;
|
leapAckMs: number;
|
||||||
strength: number;
|
strength: number;
|
||||||
planets: Array<PlanetSnapshot>;
|
planets: Array<PlanetSnapshot>;
|
||||||
horizonState?: CharacterMovementState;
|
horizonState?: PredictedMovementState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const upwards = vec2.fromValues(0, 1);
|
const upwards = vec2.fromValues(0, 1);
|
||||||
|
|
@ -59,6 +64,7 @@ export class LocalCharacterPredictor {
|
||||||
private leapHistory: Array<number> = [];
|
private leapHistory: Array<number> = [];
|
||||||
private lastLeapAckMs = -Infinity;
|
private lastLeapAckMs = -Infinity;
|
||||||
private currentStrength = settings.playerMaxStrength;
|
private currentStrength = settings.playerMaxStrength;
|
||||||
|
private predicted?: PredictedMovementState;
|
||||||
|
|
||||||
private alive = true;
|
private alive = true;
|
||||||
private previousSnapshot?: ReplaySnapshot;
|
private previousSnapshot?: ReplaySnapshot;
|
||||||
|
|
@ -79,6 +85,17 @@ export class LocalCharacterPredictor {
|
||||||
return this.renderRightFoot;
|
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 {
|
public recordInput(direction: vec2): number {
|
||||||
const timeMs = clientTimeMs();
|
const timeMs = clientTimeMs();
|
||||||
this.inputHistory.record(direction, timeMs);
|
this.inputHistory.record(direction, timeMs);
|
||||||
|
|
@ -103,6 +120,7 @@ export class LocalCharacterPredictor {
|
||||||
public recordLeap(): number {
|
public recordLeap(): number {
|
||||||
const timeMs = clientTimeMs();
|
const timeMs = clientTimeMs();
|
||||||
this.leapHistory.push(timeMs);
|
this.leapHistory.push(timeMs);
|
||||||
|
this.predicted = undefined;
|
||||||
const cutoff = timeMs - 1500;
|
const cutoff = timeMs - 1500;
|
||||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||||
this.leapHistory.shift();
|
this.leapHistory.shift();
|
||||||
|
|
@ -126,6 +144,7 @@ export class LocalCharacterPredictor {
|
||||||
this.replayAnchorMs = undefined;
|
this.replayAnchorMs = undefined;
|
||||||
this.movement = undefined;
|
this.movement = undefined;
|
||||||
this.currentStrength = settings.playerMaxStrength;
|
this.currentStrength = settings.playerMaxStrength;
|
||||||
|
this.predicted = undefined;
|
||||||
this.alive = true;
|
this.alive = true;
|
||||||
this.previousSnapshot = undefined;
|
this.previousSnapshot = undefined;
|
||||||
this.lastUpdateMs = undefined;
|
this.lastUpdateMs = undefined;
|
||||||
|
|
@ -149,6 +168,7 @@ export class LocalCharacterPredictor {
|
||||||
|
|
||||||
public update(planets: Array<PlanetView>): boolean {
|
public update(planets: Array<PlanetView>): boolean {
|
||||||
if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
if (!this.enabled || !this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||||
|
this.predicted = undefined;
|
||||||
this.previousSnapshot = undefined;
|
this.previousSnapshot = undefined;
|
||||||
this.lastUpdateMs = undefined;
|
this.lastUpdateMs = undefined;
|
||||||
vec2.zero(this.correction);
|
vec2.zero(this.correction);
|
||||||
|
|
@ -182,6 +202,7 @@ export class LocalCharacterPredictor {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const predicted = this.simulate(snapshot);
|
const predicted = this.simulate(snapshot);
|
||||||
|
this.predicted = predicted;
|
||||||
|
|
||||||
if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
|
if (this.previousSnapshot && this.previousSnapshot.pose !== snapshot.pose) {
|
||||||
// Compare both snapshots at the same instant: smooth only the correction,
|
// Compare both snapshots at the same instant: smooth only the correction,
|
||||||
|
|
@ -210,7 +231,7 @@ export class LocalCharacterPredictor {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private simulate(snapshot: ReplaySnapshot): CharacterMovementState {
|
private simulate(snapshot: ReplaySnapshot): PredictedMovementState {
|
||||||
if (snapshot.horizonState) {
|
if (snapshot.horizonState) {
|
||||||
return snapshot.horizonState;
|
return snapshot.horizonState;
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +243,7 @@ export class LocalCharacterPredictor {
|
||||||
const steps = Math.floor(windowMs / stepMs);
|
const steps = Math.floor(windowMs / stepMs);
|
||||||
const partialStep = (windowMs - steps * stepMs) / stepMs;
|
const partialStep = (windowMs - steps * stepMs) / stepMs;
|
||||||
|
|
||||||
const state: CharacterMovementState = {
|
const state: PredictedMovementState = {
|
||||||
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
||||||
leftFoot: makeBody(
|
leftFoot: makeBody(
|
||||||
auth.leftFoot.center,
|
auth.leftFoot.center,
|
||||||
|
|
@ -238,11 +259,10 @@ export class LocalCharacterPredictor {
|
||||||
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
|
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
|
||||||
secondsSinceOnSurface: movement.secondsSinceOnSurface,
|
secondsSinceOnSurface: movement.secondsSinceOnSurface,
|
||||||
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
|
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;
|
let t = startMs;
|
||||||
for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
|
for (let i = 0; i < steps + (partialStep > 0 ? 1 : 0); i++) {
|
||||||
const fraction = i < steps ? 1 : partialStep;
|
const fraction = i < steps ? 1 : partialStep;
|
||||||
|
|
@ -258,12 +278,12 @@ export class LocalCharacterPredictor {
|
||||||
leapMs < t + stepMs * fraction &&
|
leapMs < t + stepMs * fraction &&
|
||||||
leapMs > snapshot.leapAckMs &&
|
leapMs > snapshot.leapAckMs &&
|
||||||
state.currentPlanet &&
|
state.currentPlanet &&
|
||||||
cooldown <= 0 &&
|
state.leapCooldownRemaining <= 0 &&
|
||||||
availableStrength >= settings.leapStrengthCost
|
state.availableStrength >= settings.leapStrengthCost
|
||||||
) {
|
) {
|
||||||
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
|
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
|
||||||
availableStrength -= settings.leapStrengthCost;
|
state.availableStrength -= settings.leapStrengthCost;
|
||||||
cooldown = settings.leapCooldownSeconds;
|
state.leapCooldownRemaining = settings.leapCooldownSeconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.world.advance(stepSeconds);
|
this.world.advance(stepSeconds);
|
||||||
|
|
@ -274,10 +294,14 @@ export class LocalCharacterPredictor {
|
||||||
this.inputHistory.directionAt(t),
|
this.inputHistory.directionAt(t),
|
||||||
stepSeconds,
|
stepSeconds,
|
||||||
);
|
);
|
||||||
cooldown = Math.max(0, cooldown - stepSeconds);
|
state.leapCooldownRemaining = Math.max(
|
||||||
availableStrength = Math.min(
|
0,
|
||||||
|
state.leapCooldownRemaining - stepSeconds,
|
||||||
|
);
|
||||||
|
state.availableStrength = Math.min(
|
||||||
settings.playerMaxStrength,
|
settings.playerMaxStrength,
|
||||||
availableStrength + settings.playerStrengthRegenerationPerSeconds * stepSeconds,
|
state.availableStrength +
|
||||||
|
settings.playerStrengthRegenerationPerSeconds * stepSeconds,
|
||||||
);
|
);
|
||||||
if (from) {
|
if (from) {
|
||||||
[state.head, state.leftFoot, state.rightFoot].forEach((body, index) =>
|
[state.head, state.leftFoot, state.rightFoot].forEach((body, index) =>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { TouchListener } from '../frontend/src/scripts/commands/touch-listener';
|
||||||
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
|
import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler';
|
||||||
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
|
import { initializeOptions, options } from '../frontend/src/scripts/options-handler';
|
||||||
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
|
import { loadStoredValue, saveStoredValue } from '../frontend/src/scripts/helper/storage';
|
||||||
|
import { ChargeIndicator } from '../frontend/src/scripts/charge-indicator';
|
||||||
|
import { localCharacterPredictor } from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||||
|
|
||||||
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
|
const { connect } = vi.hoisted(() => ({ connect: vi.fn() }));
|
||||||
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
|
vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({
|
||||||
|
|
@ -30,8 +32,16 @@ class Element extends EventTarget {
|
||||||
public className = '';
|
public className = '';
|
||||||
public checked = false;
|
public checked = false;
|
||||||
public disabled = false;
|
public disabled = false;
|
||||||
|
public hidden = false;
|
||||||
|
private attributes = new Map<string, string>();
|
||||||
public onsubmit: ((event: Event) => void) | null = null;
|
public onsubmit: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
|
public setAttribute(name: string, value: string) {
|
||||||
|
this.attributes.set(name, value);
|
||||||
|
}
|
||||||
|
public getAttribute(name: string) {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
public appendChild(child: Element) {
|
public appendChild(child: Element) {
|
||||||
child.remove();
|
child.remove();
|
||||||
this.children.push(child);
|
this.children.push(child);
|
||||||
|
|
@ -81,6 +91,7 @@ let sockets: Socket[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
localCharacterPredictor.reset();
|
||||||
windowTarget = new EventTarget();
|
windowTarget = new EventTarget();
|
||||||
sockets = [];
|
sockets = [];
|
||||||
vi.stubGlobal('window', windowTarget);
|
vi.stubGlobal('window', windowTarget);
|
||||||
|
|
@ -103,6 +114,7 @@ afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const chooser = () => {
|
const chooser = () => {
|
||||||
|
|
@ -194,10 +206,16 @@ const touchControls = () => {
|
||||||
const canvas = new Element();
|
const canvas = new Element();
|
||||||
const overlay = new Element();
|
const overlay = new Element();
|
||||||
const send = vi.fn();
|
const send = vi.fn();
|
||||||
|
const character = {
|
||||||
|
health: 100,
|
||||||
|
bodyCenter: [0, 0],
|
||||||
|
facingDirection: [1, 0],
|
||||||
|
strengthFraction: 1,
|
||||||
|
};
|
||||||
const game = {
|
const game = {
|
||||||
displayToWorldCoordinates: (position: number[]) => position,
|
displayToWorldCoordinates: (position: number[]) => position,
|
||||||
gameObjects: {
|
gameObjects: {
|
||||||
localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 },
|
localPlayer: character as typeof character | undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const listener = new TouchListener(
|
const listener = new TouchListener(
|
||||||
|
|
@ -207,8 +225,85 @@ const touchControls = () => {
|
||||||
send,
|
send,
|
||||||
);
|
);
|
||||||
listener.update();
|
listener.update();
|
||||||
return { canvas, overlay, send, listener, fire: overlay.children[0] };
|
return {
|
||||||
|
canvas,
|
||||||
|
overlay,
|
||||||
|
send,
|
||||||
|
listener,
|
||||||
|
game,
|
||||||
|
character,
|
||||||
|
fire: overlay.children[0],
|
||||||
|
leap: overlay.children[1],
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('touch action availability', () => {
|
||||||
|
it('cancels a held shot on death and requires a fresh press after respawn', () => {
|
||||||
|
const { fire, send, listener, game, character } = touchControls();
|
||||||
|
touchEvent(fire, 'touchstart', [touch(1)]);
|
||||||
|
touchEvent(fire, 'touchmove', [touch(1, 100, 50)]);
|
||||||
|
expect(fire.children[1].style.opacity).toBe('1');
|
||||||
|
|
||||||
|
character.health = 0;
|
||||||
|
listener.update();
|
||||||
|
expect(fire.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(fire.children[1].style.opacity).toBe('0');
|
||||||
|
expect(ChargeIndicator.end).toHaveBeenCalled();
|
||||||
|
vi.mocked(ChargeIndicator.begin).mockClear();
|
||||||
|
touchEvent(fire, 'touchstart', [touch(2)]);
|
||||||
|
touchEvent(fire, 'touchend', [touch(2)], []);
|
||||||
|
expect(ChargeIndicator.begin).not.toHaveBeenCalled();
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
game.gameObjects.localPlayer = undefined;
|
||||||
|
listener.update();
|
||||||
|
touchEvent(fire, 'touchstart', [touch(3)]);
|
||||||
|
touchEvent(fire, 'touchend', [touch(3)], []);
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
game.gameObjects.localPlayer = { ...character, health: 100 };
|
||||||
|
listener.update();
|
||||||
|
expect(fire.getAttribute('aria-disabled')).toBe('false');
|
||||||
|
touchEvent(fire, 'touchend', [touch(1)], []);
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
touchEvent(fire, 'touchstart', [touch(4)]);
|
||||||
|
touchEvent(fire, 'touchend', [touch(4)], []);
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
listener.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a shot released after death before the controls update', () => {
|
||||||
|
const { fire, send, listener, character } = touchControls();
|
||||||
|
touchEvent(fire, 'touchstart', [touch(1)]);
|
||||||
|
character.health = 0;
|
||||||
|
touchEvent(fire, 'touchend', [touch(1)], []);
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
listener.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only shows and accepts jumps while the living player can leap', () => {
|
||||||
|
const canLeap = vi.spyOn(localCharacterPredictor, 'canLeap', 'get');
|
||||||
|
canLeap.mockReturnValue(false);
|
||||||
|
const { leap, send, listener, character } = touchControls();
|
||||||
|
expect(leap.hidden).toBe(true);
|
||||||
|
touchEvent(leap, 'touchstart', [touch(1)]);
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
canLeap.mockReturnValue(true);
|
||||||
|
listener.update();
|
||||||
|
expect(leap.hidden).toBe(false);
|
||||||
|
touchEvent(leap, 'touchstart', [touch(2)]);
|
||||||
|
expect(send.mock.lastCall?.[0].constructor.name).toBe('LeapActionCommand');
|
||||||
|
expect(leap.hidden).toBe(true);
|
||||||
|
|
||||||
|
character.health = 0;
|
||||||
|
listener.update();
|
||||||
|
expect(leap.hidden).toBe(true);
|
||||||
|
touchEvent(leap, 'touchstart', [touch(3)]);
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
listener.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('touch ownership', () => {
|
describe('touch ownership', () => {
|
||||||
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
|
it('keeps the first canvas gesture when a second finger lands before dragging', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { settings } from '../shared/src/settings';
|
||||||
import {
|
import {
|
||||||
LocalCharacterPredictor,
|
LocalCharacterPredictor,
|
||||||
setFrameTimeMs,
|
setFrameTimeMs,
|
||||||
|
|
@ -313,3 +314,84 @@ describe('leap prediction', () => {
|
||||||
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false));
|
expect(leapHeight(0.2, true)).toEqual(leapHeight(0.2, false));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('leap availability', () => {
|
||||||
|
const prepare = (
|
||||||
|
cooldown = 0,
|
||||||
|
strength = settings.leapStrengthCost,
|
||||||
|
groundPlanetId: number | null = ground.id,
|
||||||
|
) => {
|
||||||
|
const predictor = new LocalCharacterPredictor();
|
||||||
|
setFrameTimeMs(1000);
|
||||||
|
const pose = poseAt(0, 900);
|
||||||
|
predictor.acknowledge(
|
||||||
|
1000,
|
||||||
|
{
|
||||||
|
...movementState(),
|
||||||
|
groundPlanetId,
|
||||||
|
secondsSinceOnSurface: 0,
|
||||||
|
leapCooldownRemaining: cooldown,
|
||||||
|
} as never,
|
||||||
|
-Infinity,
|
||||||
|
);
|
||||||
|
predictor.setStrength(strength);
|
||||||
|
predictor.setAuthoritative(
|
||||||
|
pose.head as never,
|
||||||
|
pose.leftFoot as never,
|
||||||
|
pose.rightFoot as never,
|
||||||
|
);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
return { predictor, pose };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('requires a planet, sufficient strength, and an expired cooldown', () => {
|
||||||
|
expect(new LocalCharacterPredictor().canLeap).toBe(false);
|
||||||
|
expect(prepare().predictor.canLeap).toBe(true);
|
||||||
|
expect(prepare(0.2).predictor.canLeap).toBe(false);
|
||||||
|
expect(prepare(0, settings.leapStrengthCost - 1).predictor.canLeap).toBe(false);
|
||||||
|
expect(prepare(0, settings.playerMaxStrength, null).predictor.canLeap).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes available as the predicted cooldown and strength recover', () => {
|
||||||
|
const { predictor } = prepare(0.02, settings.leapStrengthCost - 1);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
setFrameTimeMs(1030);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
expect(predictor.canLeap).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables immediately after a leap and stays unavailable in flight', () => {
|
||||||
|
const { predictor } = prepare();
|
||||||
|
predictor.recordLeap();
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
setFrameTimeMs(1002);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
setFrameTimeMs(1380);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears availability on death, reset, spawn animation, and game end', () => {
|
||||||
|
const { predictor, pose } = prepare();
|
||||||
|
predictor.setAlive(false);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
predictor.setAlive(true);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
predictor.update([ground] as never);
|
||||||
|
expect(predictor.canLeap).toBe(true);
|
||||||
|
|
||||||
|
predictor.enabled = false;
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
predictor.enabled = true;
|
||||||
|
predictor.setAuthoritative(
|
||||||
|
{ ...pose.head, radius: 10 } as never,
|
||||||
|
pose.leftFoot as never,
|
||||||
|
pose.rightFoot as never,
|
||||||
|
);
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
predictor.reset();
|
||||||
|
expect(predictor.canLeap).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue