Skip to content

Commit b34e27c

Browse files
committed
Add hot-reload capacity to game.
1 parent c7f8c52 commit b34e27c

File tree

9 files changed

+167
-72
lines changed

9 files changed

+167
-72
lines changed

src/entities/playable/worm.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ export class Worm extends PlayableEntity<WormRecordedState> {
739739
} else {
740740
this.weaponSprite.position.set(
741741
this.sprite.x -
742-
(this.sprite.width + this.currentWeapon.sprite.offset.x),
742+
(this.sprite.width + this.currentWeapon.sprite.offset.x),
743743
this.sprite.y + this.currentWeapon.sprite.offset.y,
744744
);
745745
this.weaponSprite.rotation = this.fireAngle - Math.PI;
@@ -817,6 +817,11 @@ export class Worm extends PlayableEntity<WormRecordedState> {
817817

818818
destroy(): void {
819819
super.destroy();
820+
this.targettingGfx.destroy();
821+
this.wireframe.renderable.destroy();
822+
this.healthTextBox.destroy();
823+
this.weaponSprite.destroy();
824+
this.arrowSprite.destroy();
820825
// XXX: This might need to be dead.
821826
this.state.transition(InnerWormState.Inactive);
822827
if (this.isSinking) {

src/frontend/components/ingame-view.tsx

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { useEffect, useRef, useState } from "preact/hooks";
22
import styles from "./ingame-view.module.css";
3-
import { Game } from "../../game";
3+
import { type Game } from "../../game";
44
import { AmmoCount, GameReactChannel } from "../../interop/gamechannel";
55
import { WeaponSelector } from "./gameui/weapon-select";
66
import {
77
IRunningGameInstance,
88
LocalGameInstance,
99
} from "../../logic/gameinstance";
1010
import { LoadingPage } from "./loading-page";
11+
import Logger from "../../log";
12+
import { logger } from "matrix-js-sdk/lib/logger";
13+
14+
const log = new Logger('ingame-view');
15+
16+
if (import.meta.hot) {
17+
import.meta.hot.accept('../../game', (newFoo) => {
18+
log.info("New foo", newFoo);
19+
})
20+
21+
}
1122

1223
export function IngameView({
1324
scenario,
@@ -25,30 +36,67 @@ export function IngameView({
2536
const [game, setGame] = useState<Game>();
2637
const ref = useRef<HTMLDivElement>(null);
2738
const [weaponMenu, setWeaponMenu] = useState<AmmoCount | null>(null);
39+
40+
const onGameLoaded = (newGame: Game) => {
41+
if (!newGame) {
42+
return;
43+
}
44+
void newGame.loadResources();
45+
46+
log.info("Game effect", newGame);
47+
48+
// Bind the game to the window such that we can debug it.
49+
(globalThis as unknown as { wormgine: Game }).wormgine = newGame;
50+
51+
newGame.needsReload$.subscribe(() => {
52+
setLoaded(false);
53+
log.info("needs reload");
54+
import(`../../game?ts=${Date.now()}`).then((imp) =>
55+
imp.Game.create(
56+
window,
57+
scenario,
58+
gameReactChannel,
59+
gameInstance,
60+
level,
61+
)
62+
).then((g) => {
63+
setGame(g);
64+
logger.info("onGameLoaded called");
65+
onGameLoaded(g);
66+
});
67+
});
68+
69+
newGame.ready$.subscribe((r) => {
70+
if (r) {
71+
log.info("Game loaded");
72+
setLoaded(true);
73+
}
74+
});
75+
76+
newGame.run().catch((ex) => {
77+
setFatalError(ex);
78+
newGame.destroy();
79+
});
80+
};
81+
2882
useEffect(() => {
2983
async function init() {
3084
if (gameInstance instanceof LocalGameInstance) {
3185
// XXX: Only so the game feels more responsive by capturing this inside the loading phase.
3286
await gameInstance.startGame();
33-
console.log("Game started");
87+
log.info("Game started");
3488
}
35-
const game = await Game.create(
89+
const { Game } = await import("../../game");
90+
const newGame = await Game.create(
3691
window,
3792
scenario,
3893
gameReactChannel,
3994
gameInstance,
4095
level,
4196
);
42-
setGame(game);
43-
game.ready$.subscribe((r) => {
44-
if (r) {
45-
console.log("Game loaded");
46-
setLoaded(true);
47-
}
48-
});
49-
// Bind the game to the window such that we can debug it.
50-
(globalThis as unknown as { wormgine: Game }).wormgine = game;
51-
await game.loadResources();
97+
setGame(newGame);
98+
logger.info("useEffect called");
99+
onGameLoaded(newGame);
52100
}
53101

54102
void init().catch((ex) => {
@@ -60,16 +108,7 @@ export function IngameView({
60108
if (!ref.current || !game) {
61109
return;
62110
}
63-
if (ref.current.children.length > 0) {
64-
// Already bound
65-
return;
66-
}
67-
68-
ref.current.appendChild(game.canvas);
69-
game.run().catch((ex) => {
70-
setFatalError(ex);
71-
game.destroy();
72-
});
111+
ref.current.replaceChildren(game.canvas);
73112
}, [ref, game]);
74113

75114
useEffect(() => {

src/game.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Application, Graphics, UPDATE_PRIORITY } from "pixi.js";
1+
import { Application, Graphics, Ticker, UPDATE_PRIORITY } from "pixi.js";
22
import { Viewport } from "pixi-viewport";
33
import { getAssets } from "./assets";
44
import { GameDebugOverlay } from "./overlays/debugOverlay";
@@ -17,6 +17,7 @@ import { NetGameWorld } from "./net/netGameWorld";
1717
import {
1818
BehaviorSubject,
1919
debounceTime,
20+
filter,
2021
fromEvent,
2122
map,
2223
merge,
@@ -25,10 +26,14 @@ import {
2526
} from "rxjs";
2627
import { IRunningGameInstance } from "./logic/gameinstance";
2728
import { RunningNetGameInstance } from "./net/netgameinstance";
29+
import { UpdatePayload } from "vite/types/hmrPayload";
2830

2931
const worldWidth = 1920;
3032
const worldHeight = 1080;
3133

34+
// Run physics engine at 90fps.
35+
const tickEveryMs = 1000 / 90;
36+
3237
const logger = new Logger("Game");
3338

3439
export class Game {
@@ -39,6 +44,10 @@ export class Game {
3944
public readonly screenSize$: Observable<{ width: number; height: number }>;
4045
private readonly ready = new BehaviorSubject(false);
4146
public readonly ready$ = this.ready.asObservable();
47+
private lastPhysicsTick: number = 0;
48+
private overlay?: GameDebugOverlay;
49+
private readonly reloadState = new BehaviorSubject<null | any>(null);
50+
public readonly needsReload$ = this.reloadState.pipe(filter(s => s));
4251

4352
public get pixiRoot() {
4453
return this.viewport;
@@ -80,10 +89,10 @@ export class Game {
8089
this.world =
8190
netGameInstance instanceof RunningNetGameInstance
8291
? new NetGameWorld(
83-
this.rapierWorld,
84-
this.pixiApp.ticker,
85-
netGameInstance,
86-
)
92+
this.rapierWorld,
93+
this.pixiApp.ticker,
94+
netGameInstance,
95+
)
8796
: new GameWorld(this.rapierWorld, this.pixiApp.ticker);
8897
this.pixiApp.stage.addChild(this.viewport);
8998
this.viewport.decelerate().drag();
@@ -126,7 +135,7 @@ export class Game {
126135
);
127136
}
128137

129-
const overlay = new GameDebugOverlay(
138+
this.overlay = new GameDebugOverlay(
130139
this.rapierWorld,
131140
this.pixiApp.ticker,
132141
this.pixiApp.stage,
@@ -136,22 +145,9 @@ export class Game {
136145
this.pixiApp.stage.addChildAt(this.rapierGfx, 0);
137146
this.ready.next(true);
138147

139-
// Run physics engine at 90fps.
140-
const tickEveryMs = 1000 / 90;
141-
let lastPhysicsTick = 0;
142-
143-
this.pixiApp.ticker.add(
144-
(dt) => {
145-
// TODO: Timing.
146-
const startTime = performance.now();
147-
lastPhysicsTick += dt.deltaMS;
148-
// Note: If we are lagging behind terribly, this will multiple ticks
149-
while (lastPhysicsTick >= tickEveryMs) {
150-
this.world.step();
151-
lastPhysicsTick -= tickEveryMs;
152-
}
153-
overlay.physicsSamples.push(performance.now() - startTime);
154-
},
148+
import.meta.hot?.on('vite:beforeUpdate', this.hotReload);
149+
150+
this.pixiApp.ticker.add(this.tickWorld,
155151
undefined,
156152
UPDATE_PRIORITY.HIGH,
157153
);
@@ -161,7 +157,34 @@ export class Game {
161157
return this.pixiApp.canvas;
162158
}
163159

160+
public tickWorld = (dt: Ticker) => {
161+
// TODO: Timing.
162+
const startTime = performance.now();
163+
this.lastPhysicsTick += dt.deltaMS;
164+
// Note: If we are lagging behind terribly, this will run multiple ticks
165+
while (this.lastPhysicsTick >= tickEveryMs) {
166+
this.world.step();
167+
this.lastPhysicsTick -= tickEveryMs;
168+
}
169+
this.overlay?.physicsSamples.push(performance.now() - startTime);
170+
}
171+
164172
public destroy() {
173+
import.meta.hot?.off('vite:beforeUpdate', this.hotReload);
174+
this.overlay?.destroy();
165175
this.pixiApp.destroy();
176+
this.rapierWorld.free();
177+
}
178+
179+
public hotReload = (payload: UpdatePayload) => {
180+
logger.info("hot reload requested, saving game state");
181+
this.pixiApp.ticker.stop();
182+
const handler = async () => {
183+
const state = await this.gameReactChannel.saveGameState();
184+
this.destroy();
185+
logger.info("game state saved, ready to reload");
186+
this.reloadState.next(state);
187+
};
188+
void handler();
166189
}
167-
}
190+
}

src/interop/gamechannel.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type GameReactChannelEvents = {
1919
closeWeaponMenu: () => void;
2020
openWeaponMenu: (weapons: AmmoCount) => void;
2121
weaponSelected: (code: IWeaponCode) => void;
22+
saveGameState: (callback: () => void) => void;
2223
};
2324

2425
export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<GameReactChannelEvents>) {
@@ -41,4 +42,8 @@ export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<Ga
4142
public weaponMenuSelect(code: IWeaponCode) {
4243
this.emit("weaponSelected", code);
4344
}
45+
46+
public async saveGameState(): Promise<void> {
47+
return new Promise(resolve => this.emit("saveGameState", () => resolve()));
48+
}
4449
}

src/logic/gamestate.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const PREROUND_TIMER_MS = 5000;
5252
const logger = new Logger("GameState");
5353

5454
export class GameState {
55+
5556
static getTeamMaxHealth(team: TeamDefinition) {
5657
return team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b);
5758
}
@@ -65,7 +66,7 @@ export class GameState {
6566
Math.ceil(
6667
(team.worms.map((w) => w.health).reduce((a, b) => a + b) /
6768
team.worms.map((w) => w.maxHealth).reduce((a, b) => a + b)) *
68-
100,
69+
100,
6970
) / 100
7071
);
7172
}
@@ -230,6 +231,7 @@ export class GameState {
230231
public markAsFinished() {
231232
logger.info("Mark as finished");
232233
this.roundState.next(RoundState.Finished);
234+
this.remainingRoundTimeMs.next(0);
233235
}
234236

235237
public update(ticker: { deltaMS: number }) {

src/overlays/debugOverlay.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,7 @@ export class GameDebugOverlay {
5151
});
5252
this.rapierGfx = new Graphics();
5353
this.tickerFn = this.update.bind(this);
54-
globalFlags.on("toggleDebugView", (level: DebugLevel) => {
55-
if (level !== DebugLevel.None) {
56-
this.enableOverlay();
57-
} else {
58-
this.disableOverlay();
59-
}
60-
});
54+
globalFlags.on("toggleDebugView", this.toggleDebugView);
6155
if (globalFlags.DebugView) {
6256
this.enableOverlay();
6357
}
@@ -67,6 +61,14 @@ export class GameDebugOverlay {
6761
};
6862
}
6963

64+
private toggleDebugView = (level: DebugLevel) => {
65+
if (level !== DebugLevel.None) {
66+
this.enableOverlay();
67+
} else {
68+
this.disableOverlay();
69+
}
70+
}
71+
7072
private enableOverlay() {
7173
this.stage.addChild(this.text);
7274
this.viewport.addChild(this.rapierGfx);
@@ -81,6 +83,11 @@ export class GameDebugOverlay {
8183
window.removeEventListener("mousemove", this.mouseMoveListener);
8284
}
8385

86+
public destroy() {
87+
this.disableOverlay();
88+
globalFlags.off("toggleDebugView", this.toggleDebugView);
89+
}
90+
8491
private update(dt: Ticker) {
8592
this.fpsSamples.splice(0, 0, dt.FPS);
8693
while (this.fpsSamples.length > FRAME_SAMPLES) {
@@ -97,7 +104,7 @@ export class GameDebugOverlay {
97104
Math.ceil(
98105
(this.physicsSamples.reduce((a, b) => a + b, 0) /
99106
(this.physicsSamples.length || 1)) *
100-
100,
107+
100,
101108
) / 100;
102109

103110
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}`;

0 commit comments

Comments
 (0)