Skip to content

Commit

Permalink
Add hot-reload capacity to game.
Browse files Browse the repository at this point in the history
  • Loading branch information
Half-Shot committed Mar 4, 2025
1 parent c7f8c52 commit b34e27c
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 72 deletions.
7 changes: 6 additions & 1 deletion src/entities/playable/worm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ export class Worm extends PlayableEntity<WormRecordedState> {
} else {
this.weaponSprite.position.set(
this.sprite.x -
(this.sprite.width + this.currentWeapon.sprite.offset.x),
(this.sprite.width + this.currentWeapon.sprite.offset.x),
this.sprite.y + this.currentWeapon.sprite.offset.y,
);
this.weaponSprite.rotation = this.fireAngle - Math.PI;
Expand Down Expand Up @@ -817,6 +817,11 @@ export class Worm extends PlayableEntity<WormRecordedState> {

destroy(): void {
super.destroy();
this.targettingGfx.destroy();
this.wireframe.renderable.destroy();
this.healthTextBox.destroy();
this.weaponSprite.destroy();
this.arrowSprite.destroy();
// XXX: This might need to be dead.
this.state.transition(InnerWormState.Inactive);
if (this.isSinking) {
Expand Down
85 changes: 62 additions & 23 deletions src/frontend/components/ingame-view.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { useEffect, useRef, useState } from "preact/hooks";
import styles from "./ingame-view.module.css";
import { Game } from "../../game";
import { type Game } from "../../game";
import { AmmoCount, GameReactChannel } from "../../interop/gamechannel";
import { WeaponSelector } from "./gameui/weapon-select";
import {
IRunningGameInstance,
LocalGameInstance,
} from "../../logic/gameinstance";
import { LoadingPage } from "./loading-page";
import Logger from "../../log";
import { logger } from "matrix-js-sdk/lib/logger";

const log = new Logger('ingame-view');

if (import.meta.hot) {
import.meta.hot.accept('../../game', (newFoo) => {
log.info("New foo", newFoo);
})

}

export function IngameView({
scenario,
Expand All @@ -25,30 +36,67 @@ export function IngameView({
const [game, setGame] = useState<Game>();
const ref = useRef<HTMLDivElement>(null);
const [weaponMenu, setWeaponMenu] = useState<AmmoCount | null>(null);

const onGameLoaded = (newGame: Game) => {
if (!newGame) {
return;
}
void newGame.loadResources();

log.info("Game effect", newGame);

// Bind the game to the window such that we can debug it.
(globalThis as unknown as { wormgine: Game }).wormgine = newGame;

newGame.needsReload$.subscribe(() => {
setLoaded(false);
log.info("needs reload");
import(`../../game?ts=${Date.now()}`).then((imp) =>
imp.Game.create(
window,
scenario,
gameReactChannel,
gameInstance,
level,
)
).then((g) => {
setGame(g);
logger.info("onGameLoaded called");
onGameLoaded(g);
});
});

newGame.ready$.subscribe((r) => {
if (r) {
log.info("Game loaded");
setLoaded(true);
}
});

newGame.run().catch((ex) => {
setFatalError(ex);
newGame.destroy();
});
};

useEffect(() => {
async function init() {
if (gameInstance instanceof LocalGameInstance) {
// XXX: Only so the game feels more responsive by capturing this inside the loading phase.
await gameInstance.startGame();
console.log("Game started");
log.info("Game started");
}
const game = await Game.create(
const { Game } = await import("../../game");
const newGame = await Game.create(
window,
scenario,
gameReactChannel,
gameInstance,
level,
);
setGame(game);
game.ready$.subscribe((r) => {
if (r) {
console.log("Game loaded");
setLoaded(true);
}
});
// Bind the game to the window such that we can debug it.
(globalThis as unknown as { wormgine: Game }).wormgine = game;
await game.loadResources();
setGame(newGame);
logger.info("useEffect called");
onGameLoaded(newGame);
}

void init().catch((ex) => {
Expand All @@ -60,16 +108,7 @@ export function IngameView({
if (!ref.current || !game) {
return;
}
if (ref.current.children.length > 0) {
// Already bound
return;
}

ref.current.appendChild(game.canvas);
game.run().catch((ex) => {
setFatalError(ex);
game.destroy();
});
ref.current.replaceChildren(game.canvas);
}, [ref, game]);

useEffect(() => {
Expand Down
69 changes: 46 additions & 23 deletions src/game.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Application, Graphics, UPDATE_PRIORITY } from "pixi.js";
import { Application, Graphics, Ticker, UPDATE_PRIORITY } from "pixi.js";
import { Viewport } from "pixi-viewport";
import { getAssets } from "./assets";
import { GameDebugOverlay } from "./overlays/debugOverlay";
Expand All @@ -17,6 +17,7 @@ import { NetGameWorld } from "./net/netGameWorld";
import {
BehaviorSubject,
debounceTime,
filter,
fromEvent,
map,
merge,
Expand All @@ -25,10 +26,14 @@ import {
} from "rxjs";
import { IRunningGameInstance } from "./logic/gameinstance";
import { RunningNetGameInstance } from "./net/netgameinstance";
import { UpdatePayload } from "vite/types/hmrPayload";

const worldWidth = 1920;
const worldHeight = 1080;

// Run physics engine at 90fps.
const tickEveryMs = 1000 / 90;

const logger = new Logger("Game");

export class Game {
Expand All @@ -39,6 +44,10 @@ export class Game {
public readonly screenSize$: Observable<{ width: number; height: number }>;
private readonly ready = new BehaviorSubject(false);
public readonly ready$ = this.ready.asObservable();
private lastPhysicsTick: number = 0;
private overlay?: GameDebugOverlay;
private readonly reloadState = new BehaviorSubject<null | any>(null);

Check failure on line 49 in src/game.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
public readonly needsReload$ = this.reloadState.pipe(filter(s => s));

public get pixiRoot() {
return this.viewport;
Expand Down Expand Up @@ -80,10 +89,10 @@ export class Game {
this.world =
netGameInstance instanceof RunningNetGameInstance
? new NetGameWorld(
this.rapierWorld,
this.pixiApp.ticker,
netGameInstance,
)
this.rapierWorld,
this.pixiApp.ticker,
netGameInstance,
)
: new GameWorld(this.rapierWorld, this.pixiApp.ticker);
this.pixiApp.stage.addChild(this.viewport);
this.viewport.decelerate().drag();
Expand Down Expand Up @@ -126,7 +135,7 @@ export class Game {
);
}

const overlay = new GameDebugOverlay(
this.overlay = new GameDebugOverlay(
this.rapierWorld,
this.pixiApp.ticker,
this.pixiApp.stage,
Expand All @@ -136,22 +145,9 @@ export class Game {
this.pixiApp.stage.addChildAt(this.rapierGfx, 0);
this.ready.next(true);

// Run physics engine at 90fps.
const tickEveryMs = 1000 / 90;
let lastPhysicsTick = 0;

this.pixiApp.ticker.add(
(dt) => {
// TODO: Timing.
const startTime = performance.now();
lastPhysicsTick += dt.deltaMS;
// Note: If we are lagging behind terribly, this will multiple ticks
while (lastPhysicsTick >= tickEveryMs) {
this.world.step();
lastPhysicsTick -= tickEveryMs;
}
overlay.physicsSamples.push(performance.now() - startTime);
},
import.meta.hot?.on('vite:beforeUpdate', this.hotReload);

this.pixiApp.ticker.add(this.tickWorld,
undefined,
UPDATE_PRIORITY.HIGH,
);
Expand All @@ -161,7 +157,34 @@ export class Game {
return this.pixiApp.canvas;
}

public tickWorld = (dt: Ticker) => {
// TODO: Timing.
const startTime = performance.now();
this.lastPhysicsTick += dt.deltaMS;
// Note: If we are lagging behind terribly, this will run multiple ticks
while (this.lastPhysicsTick >= tickEveryMs) {
this.world.step();
this.lastPhysicsTick -= tickEveryMs;
}
this.overlay?.physicsSamples.push(performance.now() - startTime);
}

public destroy() {
import.meta.hot?.off('vite:beforeUpdate', this.hotReload);
this.overlay?.destroy();
this.pixiApp.destroy();
this.rapierWorld.free();
}

public hotReload = (payload: UpdatePayload) => {

Check failure on line 179 in src/game.ts

View workflow job for this annotation

GitHub Actions / ci

'payload' is defined but never used. Allowed unused args must match /^_/u
logger.info("hot reload requested, saving game state");
this.pixiApp.ticker.stop();
const handler = async () => {
const state = await this.gameReactChannel.saveGameState();
this.destroy();
logger.info("game state saved, ready to reload");
this.reloadState.next(state);
};
void handler();
}
}
}
5 changes: 5 additions & 0 deletions src/interop/gamechannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type GameReactChannelEvents = {
closeWeaponMenu: () => void;
openWeaponMenu: (weapons: AmmoCount) => void;
weaponSelected: (code: IWeaponCode) => void;
saveGameState: (callback: () => void) => void;
};

export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<GameReactChannelEvents>) {
Expand All @@ -41,4 +42,8 @@ export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<Ga
public weaponMenuSelect(code: IWeaponCode) {
this.emit("weaponSelected", code);
}

public async saveGameState(): Promise<void> {
return new Promise(resolve => this.emit("saveGameState", () => resolve()));
}
}
4 changes: 3 additions & 1 deletion src/logic/gamestate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const PREROUND_TIMER_MS = 5000;
const logger = new Logger("GameState");

export class GameState {

static getTeamMaxHealth(team: TeamDefinition) {
return team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b);
}
Expand All @@ -65,7 +66,7 @@ export class GameState {
Math.ceil(
(team.worms.map((w) => w.health).reduce((a, b) => a + b) /
team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b)) *
100,
100,
) / 100
);
}
Expand Down Expand Up @@ -230,6 +231,7 @@ export class GameState {
public markAsFinished() {
logger.info("Mark as finished");
this.roundState.next(RoundState.Finished);
this.remainingRoundTimeMs.next(0);
}

public update(ticker: { deltaMS: number }) {
Expand Down
23 changes: 15 additions & 8 deletions src/overlays/debugOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,7 @@ export class GameDebugOverlay {
});
this.rapierGfx = new Graphics();
this.tickerFn = this.update.bind(this);
globalFlags.on("toggleDebugView", (level: DebugLevel) => {
if (level !== DebugLevel.None) {
this.enableOverlay();
} else {
this.disableOverlay();
}
});
globalFlags.on("toggleDebugView", this.toggleDebugView);
if (globalFlags.DebugView) {
this.enableOverlay();
}
Expand All @@ -67,6 +61,14 @@ export class GameDebugOverlay {
};
}

private toggleDebugView = (level: DebugLevel) => {
if (level !== DebugLevel.None) {
this.enableOverlay();
} else {
this.disableOverlay();
}
}

private enableOverlay() {
this.stage.addChild(this.text);
this.viewport.addChild(this.rapierGfx);
Expand All @@ -81,6 +83,11 @@ export class GameDebugOverlay {
window.removeEventListener("mousemove", this.mouseMoveListener);
}

public destroy() {
this.disableOverlay();
globalFlags.off("toggleDebugView", this.toggleDebugView);
}

private update(dt: Ticker) {
this.fpsSamples.splice(0, 0, dt.FPS);
while (this.fpsSamples.length > FRAME_SAMPLES) {
Expand All @@ -97,7 +104,7 @@ export class GameDebugOverlay {
Math.ceil(
(this.physicsSamples.reduce((a, b) => a + b, 0) /
(this.physicsSamples.length || 1)) *
100,
100,
) / 100;

this.text.text = `FPS: ${avgFps} | Physics time: ${avgPhysicsCostMs}ms| Total bodies: ${this.rapierWorld.bodies.len()} | Mouse: ${Math.round(this.mouse.x)} ${Math.round(this.mouse.y)} | Ticker fns: ${this.ticker.count}`;
Expand Down
Loading

0 comments on commit b34e27c

Please sign in to comment.