diff --git a/frontend/src/main.scss b/frontend/src/main.scss index b9c6a92..3e65033 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -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); diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 3af1895..e92dd9e 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -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 diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 234e8c6..9c0d5aa 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -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; diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index ccf248b..1e4acd6 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -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; - horizonState?: CharacterMovementState; + horizonState?: PredictedMovementState; } const upwards = vec2.fromValues(0, 1); @@ -59,6 +64,7 @@ export class LocalCharacterPredictor { private leapHistory: Array = []; 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): 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) => diff --git a/test/frontend-regressions.test.ts b/test/frontend-regressions.test.ts index 5379f5b..aaf1bce 100644 --- a/test/frontend-regressions.test.ts +++ b/test/frontend-regressions.test.ts @@ -6,6 +6,8 @@ import { TouchListener } from '../frontend/src/scripts/commands/touch-listener'; import { JoinFormHandler } from '../frontend/src/scripts/join-form-handler'; import { initializeOptions, options } from '../frontend/src/scripts/options-handler'; 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() })); vi.mock('../frontend/node_modules/socket.io-client/build/esm-debug/index.js', () => ({ @@ -30,8 +32,16 @@ class Element extends EventTarget { public className = ''; public checked = false; public disabled = false; + public hidden = false; + private attributes = new Map(); 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) { child.remove(); this.children.push(child); @@ -81,6 +91,7 @@ let sockets: Socket[]; beforeEach(() => { vi.useFakeTimers(); + localCharacterPredictor.reset(); windowTarget = new EventTarget(); sockets = []; vi.stubGlobal('window', windowTarget); @@ -103,6 +114,7 @@ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.clearAllMocks(); + vi.restoreAllMocks(); }); const chooser = () => { @@ -194,10 +206,16 @@ const touchControls = () => { const canvas = new Element(); const overlay = new Element(); const send = vi.fn(); + const character = { + health: 100, + bodyCenter: [0, 0], + facingDirection: [1, 0], + strengthFraction: 1, + }; const game = { displayToWorldCoordinates: (position: number[]) => position, gameObjects: { - localPlayer: { bodyCenter: [0, 0], facingDirection: [1, 0], strengthFraction: 1 }, + localPlayer: character as typeof character | undefined, }, }; const listener = new TouchListener( @@ -207,9 +225,86 @@ const touchControls = () => { send, ); 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', () => { it('keeps the first canvas gesture when a second finger lands before dragging', () => { const { canvas, send, listener } = touchControls(); diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index eed53f0..a5c5488 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { settings } from '../shared/src/settings'; import { LocalCharacterPredictor, setFrameTimeMs, @@ -313,3 +314,84 @@ describe('leap prediction', () => { 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); + }); +});