ai
This commit is contained in:
parent
b6db7e8dc7
commit
d9b80b92ca
22 changed files with 563 additions and 62 deletions
|
|
@ -332,6 +332,26 @@ body {
|
|||
&::after {
|
||||
content: '\25C9';
|
||||
}
|
||||
|
||||
// Twin-stick aim indicator: drawn from the button centre toward the
|
||||
// current drag direction while aiming a shot (see TouchListener).
|
||||
.aim-line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 130px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
transform-origin: left center;
|
||||
transform: translateY(-50%) rotate(0rad);
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0.85),
|
||||
rgba(255, 255, 255, 0));
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 80ms;
|
||||
}
|
||||
}
|
||||
|
||||
&.leap {
|
||||
|
|
@ -717,6 +737,34 @@ body {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centred "Eliminated" overlay shown while the local player is dead; the
|
||||
// respawn countdown is the server-driven "Reviving in N…" announcement.
|
||||
.elimination {
|
||||
position: absolute;
|
||||
top: 42%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
animation: elimination-in 220ms ease-out;
|
||||
|
||||
.elimination-title {
|
||||
font-weight: 800;
|
||||
font-size: 3.4rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: $accent;
|
||||
text-shadow:
|
||||
0 0 12px rgba(0, 0, 0, 0.9),
|
||||
0 0 28px rgba($accent, 0.6);
|
||||
}
|
||||
|
||||
.elimination-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 1.3rem;
|
||||
opacity: 0.85;
|
||||
text-shadow: 0 0 8px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contested-pulse {
|
||||
|
|
@ -877,4 +925,16 @@ body {
|
|||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes elimination-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -42%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { holdDurationToCharge } from 'shared';
|
||||
import { Pointer } from './helper/pointer';
|
||||
|
||||
|
||||
export abstract class ChargeIndicator {
|
||||
private static element?: HTMLElement;
|
||||
private static heldSince = 0;
|
||||
|
|
@ -41,8 +40,9 @@ export abstract class ChargeIndicator {
|
|||
(performance.now() - ChargeIndicator.heldSince) / 1000,
|
||||
);
|
||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360
|
||||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
|
||||
charge * 360
|
||||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||
element.classList.toggle('full', charge >= 1);
|
||||
|
||||
if (ChargeIndicator.followPointer) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import { localCharacterPredictor } from '../helper/prediction/local-character-pr
|
|||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
private static readonly deltaScaling = 0.4;
|
||||
// Min screen drag (px) from the fire button before a shot is aimed by the
|
||||
// drag direction instead of firing straight ahead.
|
||||
private static readonly aimDeadZone = 18;
|
||||
|
||||
private joystick: HTMLElement;
|
||||
private joystickButton: HTMLElement;
|
||||
|
|
@ -24,9 +27,12 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
private fireAimLine!: HTMLElement;
|
||||
private leapButton: HTMLElement;
|
||||
|
||||
private fireDownAt: number | null = null;
|
||||
private fireButtonCenter: vec2 | null = null;
|
||||
private fireAimScreen: vec2 | null = null;
|
||||
|
||||
constructor(
|
||||
private target: HTMLElement,
|
||||
|
|
@ -45,8 +51,12 @@ export class TouchListener extends CommandGenerator {
|
|||
this.fireStrengthRing = document.createElement('div');
|
||||
this.fireStrengthRing.className = 'strength-ring';
|
||||
this.fireButton.appendChild(this.fireStrengthRing);
|
||||
this.fireAimLine = document.createElement('div');
|
||||
this.fireAimLine.className = 'aim-line';
|
||||
this.fireButton.appendChild(this.fireAimLine);
|
||||
|
||||
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.leapButton = document.createElement('div');
|
||||
|
|
@ -159,12 +169,41 @@ export class TouchListener extends CommandGenerator {
|
|||
this.swallowTouch(event);
|
||||
this.fireDownAt = performance.now();
|
||||
const rect = this.fireButton.getBoundingClientRect();
|
||||
ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
this.fireButtonCenter = vec2.fromValues(
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2,
|
||||
);
|
||||
this.fireAimScreen = null;
|
||||
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
|
||||
};
|
||||
|
||||
// Dragging from the fire button aims the shot: the drag vector sets the
|
||||
// direction, decoupling aim from movement so a touch player can fire one way
|
||||
// while walking another. A tap with no meaningful drag fires straight ahead.
|
||||
private fireButtonMoveListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
if (this.fireDownAt === null || !this.fireButtonCenter) {
|
||||
return;
|
||||
}
|
||||
const touch = event.targetTouches[0] ?? event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||
this.fireAimLine.style.opacity = '1';
|
||||
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
|
||||
} else {
|
||||
this.fireAimLine.style.opacity = '0';
|
||||
}
|
||||
};
|
||||
|
||||
private fireButtonUpListener = (event: TouchEvent) => {
|
||||
this.swallowTouch(event);
|
||||
ChargeIndicator.end();
|
||||
this.fireAimLine.style.opacity = '0';
|
||||
if (this.fireDownAt === null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -174,12 +213,28 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (!character) {
|
||||
this.fireButtonCenter = null;
|
||||
this.fireAimScreen = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Screen drag → world aim direction (flip Y: screen +y is down). Below the
|
||||
// dead-zone it's a tap, so fall back to firing along the facing direction.
|
||||
let direction = character.facingDirection;
|
||||
if (this.fireButtonCenter && this.fireAimScreen) {
|
||||
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
|
||||
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
|
||||
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
|
||||
direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
|
||||
}
|
||||
}
|
||||
this.fireButtonCenter = null;
|
||||
this.fireAimScreen = null;
|
||||
|
||||
const aim = vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
character.bodyCenter,
|
||||
character.facingDirection,
|
||||
direction,
|
||||
settings.touchAimRange,
|
||||
);
|
||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
||||
|
|
@ -195,8 +250,9 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (character) {
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||
character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +263,7 @@ export class TouchListener extends CommandGenerator {
|
|||
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Pointer } from './helper/pointer';
|
|||
export abstract class FeedbackHud {
|
||||
private static root?: HTMLElement;
|
||||
private static killfeed?: HTMLElement;
|
||||
private static elimination?: HTMLElement;
|
||||
|
||||
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
||||
if (!this.root || !this.killfeed) {
|
||||
|
|
@ -75,6 +76,29 @@ export abstract class FeedbackHud {
|
|||
}
|
||||
}
|
||||
|
||||
// Persistent centred overlay shown while the local player is dead and waiting
|
||||
// to respawn. The countdown itself is the server-driven "Reviving in N…"
|
||||
// announcement; this makes the death state unmistakable and stays up until
|
||||
// hideElimination() is called on respawn.
|
||||
public static showElimination(): void {
|
||||
if (this.elimination) {
|
||||
return;
|
||||
}
|
||||
const { root } = this.ensureRoot();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'elimination';
|
||||
el.innerHTML =
|
||||
'<div class="elimination-title">Eliminated</div>' +
|
||||
'<div class="elimination-sub">Respawning…</div>';
|
||||
root.appendChild(el);
|
||||
this.elimination = el;
|
||||
}
|
||||
|
||||
public static hideElimination(): void {
|
||||
this.elimination?.parentElement?.removeChild(this.elimination);
|
||||
this.elimination = undefined;
|
||||
}
|
||||
|
||||
private static streakName(streak: number): string | undefined {
|
||||
switch (streak) {
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ import { InputHistory } from './input-history';
|
|||
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// Clock source for the predictor. Injectable so deterministic reconciliation
|
||||
// tests can drive prediction without real time passing; defaults to the
|
||||
// browser wall clock in production.
|
||||
let nowMs: () => number = () => performance.now();
|
||||
export const setPredictorClockForTesting = (clock: () => number): void => {
|
||||
nowMs = clock;
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -74,6 +82,12 @@ export class LocalCharacterPredictor {
|
|||
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||
private currentStrength = settings.playerMaxStrength;
|
||||
|
||||
// Whether the local body is alive on the server. While dead (awaiting respawn)
|
||||
// prediction is suppressed so the corpse/ghost can't keep walking in response
|
||||
// to input — the server ignores a dead player's movement, so a predicted body
|
||||
// that still moved would be a pure client-side desync.
|
||||
private alive = true;
|
||||
|
||||
// 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
|
||||
|
|
@ -100,7 +114,7 @@ export class LocalCharacterPredictor {
|
|||
// 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());
|
||||
const timeMs = Math.round(nowMs());
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
|
@ -112,7 +126,10 @@ export class LocalCharacterPredictor {
|
|||
): 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) {
|
||||
if (
|
||||
this.lastAckClientTimeMs === undefined ||
|
||||
clientTimeMs > this.lastAckClientTimeMs
|
||||
) {
|
||||
this.lastAckClientTimeMs = clientTimeMs;
|
||||
}
|
||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||
|
|
@ -121,7 +138,7 @@ export class LocalCharacterPredictor {
|
|||
|
||||
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
|
||||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
this.authReceiptMs = Math.round(performance.now());
|
||||
this.authReceiptMs = Math.round(nowMs());
|
||||
}
|
||||
|
||||
// The player pressed leap; remember when, so the replay applies the same
|
||||
|
|
@ -129,7 +146,7 @@ export class LocalCharacterPredictor {
|
|||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||
// authoritative momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(performance.now());
|
||||
const timeMs = Math.round(nowMs());
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
|
|
@ -142,6 +159,10 @@ export class LocalCharacterPredictor {
|
|||
this.currentStrength = strength;
|
||||
}
|
||||
|
||||
public setAlive(alive: boolean): void {
|
||||
this.alive = alive;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.inputHistory.reset();
|
||||
this.leapHistory = [];
|
||||
|
|
@ -154,6 +175,7 @@ export class LocalCharacterPredictor {
|
|||
this.carriedPlanetId = undefined;
|
||||
this.carriedSecondsSinceSurface = 1;
|
||||
this.hasRender = false;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
public get canPredict(): boolean {
|
||||
|
|
@ -175,7 +197,7 @@ export class LocalCharacterPredictor {
|
|||
// 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) {
|
||||
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||
// Resume from the authoritative pose with a snap rather than gliding from
|
||||
// a stale rendered one.
|
||||
this.hasRender = false;
|
||||
|
|
@ -209,7 +231,7 @@ export class LocalCharacterPredictor {
|
|||
|
||||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const now = Math.round(performance.now());
|
||||
const now = Math.round(nowMs());
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ import {
|
|||
import { settings, rgb, PlanetBase, Random } from 'shared';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
|
||||
// colorMixQ ends of PlanetShape's blue<->red gradient, so the two backdrop
|
||||
// planets read as the two in-game teams.
|
||||
// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
|
||||
// read as the two in-game teams; the red planet is pulled a little off the
|
||||
// pure-red end so it shows as a more muted, less saturated red.
|
||||
const bluePlanet = 0;
|
||||
const redPlanet = 1;
|
||||
const redPlanet = 0.85;
|
||||
|
||||
export class LandingPageBackground {
|
||||
private isActive = true;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'shared';
|
||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||
import { StepCommand } from '../commands/types/step';
|
||||
import { FeedbackHud } from '../feedback-hud';
|
||||
import { Game } from '../game';
|
||||
import { serverTimeline } from '../helper/server-timeline';
|
||||
import { PredictablePlanet } from '../helper/prediction/client-character-world';
|
||||
|
|
@ -28,6 +29,7 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
protected objects: Map<Id, GameObject> = new Map();
|
||||
public player!: CharacterView;
|
||||
public camera: Camera = new Camera(this.game);
|
||||
private wasLocalPlayerAlive = false;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
|
||||
|
|
@ -37,6 +39,9 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
// 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();
|
||||
// Respawned — clear the elimination overlay.
|
||||
FeedbackHud.hideElimination();
|
||||
this.wasLocalPlayerAlive = true;
|
||||
},
|
||||
|
||||
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
|
||||
|
|
@ -45,15 +50,32 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
[StepCommand.type]: (c: StepCommand) => {
|
||||
this.defaultCommandExecutor(c);
|
||||
|
||||
if (this.player) {
|
||||
// The local body is alive only while its object still exists (the server
|
||||
// deletes it on death) and its health is above zero — `player` keeps
|
||||
// pointing at the now-stale view after death, so both checks are needed.
|
||||
const bodyPresent = !!this.player && this.objects.has(this.player.id);
|
||||
const alive = bodyPresent && this.player.health > 0;
|
||||
|
||||
// Show the elimination overlay on the alive→dead edge; CreatePlayerCommand
|
||||
// clears it on respawn.
|
||||
if (this.wasLocalPlayerAlive && !alive) {
|
||||
FeedbackHud.showElimination();
|
||||
}
|
||||
this.wasLocalPlayerAlive = alive;
|
||||
|
||||
if (bodyPresent) {
|
||||
// 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.
|
||||
// one so it responds to input immediately. Suppressed while dead so the
|
||||
// corpse can't be walked around (the server ignores a dead player's
|
||||
// input — a moving predicted body would be a pure client-side desync).
|
||||
// A large correction (respawn / death) snaps inside the predictor.
|
||||
localCharacterPredictor.setAlive(alive);
|
||||
localCharacterPredictor.setStrength(
|
||||
this.player.strengthFraction * settings.playerMaxStrength,
|
||||
);
|
||||
if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
|
||||
if (
|
||||
localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
|
||||
) {
|
||||
this.player.head = localCharacterPredictor.head;
|
||||
this.player.leftFoot = localCharacterPredictor.leftFoot;
|
||||
this.player.rightFoot = localCharacterPredictor.rightFoot;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +141,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
) {
|
||||
super(vertices);
|
||||
|
||||
this.cullCenter = vertices.reduce(
|
||||
(sum, v) => vec2.add(sum, sum, v),
|
||||
vec2.create(),
|
||||
);
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue