go to player on spawn start (#3802)

## Description:

Some new players were having trouble finding themselves on game start

* Emits a GoToPlayerEvent (zoom=8) on the first turn after the spawn
phase, using a hasGoneToPlayer flag to ensure it only fires once per
session
* Adds a zoom parameter to GoToPlayerEvent so callers can specify a
target zoom level
* Adds smooth zoom animation to TransformHandler — the camera now eases
to the target scale alongside the existing position easing, with
screen-center correction to avoid visual jumping on mobile (where canvas
and map dimensions differ)
* Moves GoToPlayerEvent, GoToPositionEvent, and GoToUnitEvent out of
Leaderboard.ts into TransformHandler.ts, where they logically belong

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-29 22:29:41 -06:00
committed by GitHub
parent f304141338
commit 1776ae4f35
5 changed files with 70 additions and 34 deletions
+13 -2
View File
@@ -53,7 +53,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import { GoToPlayerEvent } from "./graphics/TransformHandler";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
@@ -441,6 +441,8 @@ export class ClientGameRunner {
console.log("Connected to game server!");
this.transport.rejoinGame(this.turnsSeen);
};
let hasGoneToPlayer = false;
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type === "start") {
@@ -472,7 +474,7 @@ export class ClientGameRunner {
return;
}
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
this.eventBus.emit(new GoToPlayerEvent(myPlayer, 10));
};
goToPlayer();
@@ -519,6 +521,15 @@ export class ClientGameRunner {
);
}
if (message.type === "turn") {
if (
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
this.gameView.myPlayer()
) {
hasGoneToPlayer = true;
this.eventBus.emit(new GoToPlayerEvent(this.gameView.myPlayer()!, 8));
}
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
+50 -11
View File
@@ -1,12 +1,25 @@
import { EventBus } from "../../core/EventBus";
import { EventBus, GameEvent } from "../../core/EventBus";
import { Cell } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./layers/Leaderboard";
export class GoToPlayerEvent implements GameEvent {
constructor(
public player: PlayerView,
public zoom?: number,
) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
export const GOTO_INTERVAL_MS = 16;
export const CAMERA_MAX_SPEED = 15;
@@ -20,6 +33,7 @@ export class TransformHandler {
private lastGoToCallTime: number | null = null;
private target: Cell | null;
private targetScale: number | null = null;
private intervalID: NodeJS.Timeout | null = null;
private changed = false;
@@ -183,6 +197,7 @@ export class TransformHandler {
return;
}
this.target = new Cell(nameLocation.x, nameLocation.y);
this.targetScale = event.zoom ?? null;
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
@@ -214,10 +229,12 @@ export class TransformHandler {
if (this.target === null) throw new Error("null target");
if (
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
2
) {
const positionClose =
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) < 2;
const scaleClose =
this.targetScale === null ||
Math.abs(this.scale - this.targetScale) < 0.01;
if (positionClose && scaleClose) {
this.clearTarget();
return;
}
@@ -242,6 +259,27 @@ export class TransformHandler {
-CAMERA_MAX_SPEED,
);
if (this.targetScale !== null) {
const oldScale = this.scale;
const zoomSmoothing = 0.7;
const zoomR = 1 - Math.pow(zoomSmoothing, dt / 1000);
const diff = this.targetScale - this.scale;
const smoothStep = diff * zoomR;
const minStep =
Math.sign(diff) * Math.min(Math.abs(diff), (6.0 * dt) / 1000);
this.scale +=
Math.abs(smoothStep) >= Math.abs(minStep) ? smoothStep : minStep;
// Keep screen center pinned as scale changes: (canvasSize - mapSize) / (2 * scale)
// shifts the apparent center when canvas != map dimensions (always on mobile).
const { width: canvasWidth, height: canvasHeight } = this.boundingRect();
this.offsetX +=
(canvasWidth - this.game.width()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
this.offsetY +=
(canvasHeight - this.game.height()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
}
this.changed = true;
}
@@ -321,6 +359,7 @@ export class TransformHandler {
this.intervalID = null;
}
this.target = null;
this.targetScale = null;
}
override(x: number = 0, y: number = 0, s: number = 1) {
+3 -3
View File
@@ -16,13 +16,13 @@ import {
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { getColoredSprite } from "../SpriteLoader";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
} from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
+1 -1
View File
@@ -34,7 +34,7 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { GoToPlayerEvent, GoToUnitEvent } from "../TransformHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
+3 -17
View File
@@ -2,9 +2,10 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { formatPercentage, renderNumber } from "../../Utils";
import { GoToPlayerEvent } from "../TransformHandler";
import { Layer } from "./Layer";
interface Entry {
@@ -18,21 +19,6 @@ interface Entry {
player: PlayerView;
}
export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView | null = null;