Skip to content

Commit

Permalink
[feat] Hot reload support.
Browse files Browse the repository at this point in the history
  • Loading branch information
Half-Shot committed Mar 10, 2025
1 parent 6b3f458 commit 040e713
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 46 deletions.
3 changes: 2 additions & 1 deletion src/frontend/components/ingame-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function IngameView({
// Bind the game to the window such that we can debug it.
(globalThis as unknown as { wormgine: Game }).wormgine = newGame;

newGame.needsReload$.subscribe(() => {
newGame.needsReload$.subscribe((previousState) => {
setLoaded(false);
log.info("needs reload");
import(`../../game?ts=${Date.now()}`)
Expand All @@ -58,6 +58,7 @@ export function IngameView({
gameReactChannel,
gameInstance,
level,
previousState,
),
)
.then((g) => {
Expand Down
47 changes: 26 additions & 21 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const tickEveryMs = 1000 / 90;

const logger = new Logger("Game");

export class Game {
export class Game<ReloadedGameState extends object = {}> {
public readonly viewport: Viewport;
private readonly rapierWorld: RAPIER.World;
public readonly world: GameWorld;
Expand All @@ -44,33 +44,35 @@ export class Game {
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 | boolean>(null);
public overlay?: GameDebugOverlay;
private readonly reloadState = new BehaviorSubject<ReloadedGameState | null>(null);
public readonly needsReload$ = this.reloadState.pipe(filter((s) => !!s));

public get pixiRoot() {
return this.viewport;
}

public static async create(
public static async create<ReloadedGameState extends object>(
window: Window,
scenario: string,
gameReactChannel: GameReactChannel,
gameReactChannel: GameReactChannel<ReloadedGameState>,
gameInstance: IRunningGameInstance,
level?: string,
): Promise<Game> {
previousGameState?: ReloadedGameState,
): Promise<Game<ReloadedGameState>> {
await RAPIER.init();
const pixiApp = new Application();
await pixiApp.init({ resizeTo: window, preference: "webgl" });
return new Game(pixiApp, scenario, gameReactChannel, gameInstance, level);
return new Game(pixiApp, scenario, gameReactChannel, gameInstance, level, previousGameState);
}

constructor(
public readonly pixiApp: Application,
private readonly scenario: string,
public readonly gameReactChannel: GameReactChannel,
public readonly gameReactChannel: GameReactChannel<ReloadedGameState>,
public readonly netGameInstance: IRunningGameInstance,
public readonly level?: string,
public readonly previousGameState?: ReloadedGameState,
) {
// TODO: Set a sensible static width/height and have the canvas pan it.
this.rapierWorld = new RAPIER.World({ x: 0, y: 9.81 });
Expand All @@ -88,10 +90,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 @@ -124,6 +126,16 @@ export class Game {
if (this.scenario.replaceAll(/[A-Za-z]/g, "") !== "") {
throw new CriticalGameError(Error("Invalid level name"));
}


this.overlay = new GameDebugOverlay(
this.rapierWorld,
this.pixiApp.ticker,
this.pixiApp.stage,
this.viewport,
undefined,
);

try {
logger.info(`Loading scenario ${this.scenario}`);
const module = await import(`./scenarios/${this.scenario}.ts`);
Expand All @@ -134,13 +146,6 @@ export class Game {
);
}

this.overlay = new GameDebugOverlay(
this.rapierWorld,
this.pixiApp.ticker,
this.pixiApp.stage,
this.viewport,
undefined,
);
this.pixiApp.stage.addChildAt(this.rapierGfx, 0);
this.ready.next(true);

Expand Down Expand Up @@ -176,10 +181,10 @@ export class Game {
logger.info("hot reload requested, saving game state");
this.pixiApp.ticker.stop();
const handler = async () => {
await this.gameReactChannel.saveGameState();
const state = await this.gameReactChannel.saveGameState();
this.destroy();
logger.info("game state saved, ready to reload");
this.reloadState.next(true);
this.reloadState.next(state);
};
void handler();
};
Expand Down
10 changes: 5 additions & 5 deletions src/interop/gamechannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ interface WinDetails {

export type AmmoCount = [IWeaponDefiniton, number][];

type GameReactChannelEvents = {
type GameReactChannelEvents<ReloadedGameState extends object> = {
goToMenu: (event: GoToMenuEvent) => void;
closeWeaponMenu: () => void;
openWeaponMenu: (weapons: AmmoCount) => void;
weaponSelected: (code: IWeaponCode) => void;
saveGameState: (callback: () => void) => void;
saveGameState: (callback: (state: ReloadedGameState) => void) => void;
};

export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<GameReactChannelEvents>) {
export class GameReactChannel<ReloadedGameState extends object = object> extends (EventEmitter as new () => TypedEmitter<GameReactChannelEvents<object>>) {
constructor() {
super();
}
Expand All @@ -43,9 +43,9 @@ export class GameReactChannel extends (EventEmitter as new () => TypedEmitter<Ga
this.emit("weaponSelected", code);
}

public async saveGameState(): Promise<void> {
public async saveGameState(): Promise<ReloadedGameState> {
return new Promise((resolve) =>
this.emit("saveGameState", () => resolve()),
this.emit("saveGameState", (state) => resolve(state as ReloadedGameState)),
);
}
}
18 changes: 16 additions & 2 deletions src/overlays/debugOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export class GameDebugOverlay {
private mouse: Point = new Point();
private mouseMoveListener: (e: MouseEvent) => void;

private readonly textFields = new Set<{ text: string }>();

private static registeredDebugPoints: Record<
string,
{ points: Point[]; color: ColorSource }
Expand Down Expand Up @@ -61,6 +63,12 @@ export class GameDebugOverlay {
};
}

public addTextField() {
const newTextField = { text: "" };
this.textFields.add(newTextField);
return newTextField;
}

private toggleDebugView = (level: DebugLevel) => {
if (level !== DebugLevel.None) {
this.enableOverlay();
Expand Down Expand Up @@ -104,10 +112,16 @@ 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}`;
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}`,
].concat(this.textFields.values().filter(v => !!v.text).map(v => v.text).toArray()).join(' | ')

this.skippedUpdatesTarget = 180 / avgFps;

Expand Down
57 changes: 40 additions & 17 deletions src/scenarios/netGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ import { combineLatest, filter } from "rxjs";
import { RoundState } from "../logic/gamestate";
import { RunningNetGameInstance } from "../net/netgameinstance";
import { Mine } from "../entities/phys/mine";
import { PlayableCondition } from "../entities/playable/conditions";

const log = new Logger("scenario");

export default async function runScenario(game: Game) {
interface HotReloadGameState {
iteration: number;
}

export default async function runScenario(game: Game<HotReloadGameState>) {
if (!game.level) {
throw Error("Level required!");
}
Expand All @@ -44,8 +49,16 @@ export default async function runScenario(game: Game) {
const { worldWidth } = game.viewport;
const wormInstances = new Map<string, Worm>();

const iteration = game.previousGameState?.iteration || 1;
const iterField = game.overlay?.addTextField();
if (iterField) {
iterField.text = `Iteration: ${iteration}`;
}

game.gameReactChannel.on("saveGameState", (cb) => {
cb();
cb({
iteration: iteration + 1
} satisfies HotReloadGameState);
});

const stateRecorder = new StateRecorder({
Expand Down Expand Up @@ -223,24 +236,26 @@ export default async function runScenario(game: Game) {
const wormEnt = world.addEntity(
wormInstance.team.playerUserId === myUserId
? Worm.create(
parent,
world,
pos,
wormInstance,
fireFn,
overlay.toaster,
stateRecorder,
)
parent,
world,
pos,
wormInstance,
fireFn,
overlay.toaster,
stateRecorder,
)
: RemoteWorm.create(
parent,
world,
pos,
wormInstance,
fireFn,
overlay.toaster,
),
parent,
world,
pos,
wormInstance,
fireFn,
overlay.toaster,
),
wormInstance.uuid,
);
wormEnt.addCondition(PlayableCondition.Sickness);
wormEnt.addCondition(PlayableCondition.Metallic);
wormInstances.set(wormInstance.uuid, wormEnt);
}
}
Expand Down Expand Up @@ -301,6 +316,14 @@ export default async function runScenario(game: Game) {
}
}


combineLatest([gameState.roundState$])
.pipe(filter(([state]) => state === RoundState.Finished))
.subscribe(() => {
log.info("Round tick")
wormInstances.forEach(w => w.roundTick());
});

combineLatest([gameState.roundState$, gameState.remainingRoundTimeSeconds$])
.pipe(filter(([state]) => state === RoundState.Finished))
.subscribe(() => {
Expand Down

0 comments on commit 040e713

Please sign in to comment.