deadhead-space-freight-tycoon/apps/web/src/platform/presence.ts

98 lines
3.2 KiB
TypeScript

import { App } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { create } from "zustand";
/** A stretch of time the player was away: the game hidden, in the background, or closed. */
export interface Absence {
from: number;
to: number;
}
/**
* Whether the player is "here" (decision 0.5.160): the tab visible or the app
* in the foreground. `lastReturn` is the absence that ended most recently
* (a cold start counts as coming back from the save's last time seen);
* `returns` counts comebacks, so a component can react to each one.
*/
export interface PresenceState {
here: boolean;
awaySince: number | null;
lastReturn: Absence | null;
returns: number;
}
/** The state when the game starts: back from `savedAt` (null for a new game) if it opens in front. */
export function presenceAtStart(here: boolean, now: number, savedAt: number | null): PresenceState {
if (!here) return { here, awaySince: savedAt ?? now, lastReturn: null, returns: 0 };
return {
here,
awaySince: null,
lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null,
returns: savedAt !== null && savedAt < now ? 1 : 0,
};
}
/** Going away or coming back at `now`; anything else changes nothing. */
export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState {
if (here === state.here) return state;
if (!here) return { ...state, here, awaySince: now };
return {
here,
awaySince: null,
lastReturn: { from: state.awaySince ?? now, to: now },
returns: state.returns + 1,
};
}
export const usePresence = create<PresenceState>(() => ({
here: true,
awaySince: null,
lastReturn: null,
returns: 0,
}));
/** Where "here" comes from: the page's visibility, or the native app's state. */
export interface PresenceHost {
isHere(): boolean;
/** Calls `fn` with each change; returns an unsubscribe. */
onChange(fn: (here: boolean) => void): () => void;
}
/** The browser tab (Page Visibility API). */
export const browserPresence: PresenceHost = {
isHere: () => document.visibilityState === "visible",
onChange(fn) {
const listener = () => fn(document.visibilityState === "visible");
document.addEventListener("visibilitychange", listener);
return () => document.removeEventListener("visibilitychange", listener);
},
};
/** The native app (Capacitor app state): in the foreground or not. */
export const nativePresence: PresenceHost = {
isHere: () => document.visibilityState === "visible",
onChange(fn) {
const handle = App.addListener("appStateChange", ({ isActive }) => fn(isActive));
return () => void handle.then((h) => h.remove());
},
};
export function platformPresence(): PresenceHost {
return Capacitor.isNativePlatform() ? nativePresence : browserPresence;
}
/**
* Tracks presence into `usePresence` (CR-028 Step 28.2), with times from the
* game's clock so they compare with logbook times. Returns a stop function.
*/
export function startPresence(
host: PresenceHost,
now: () => number,
savedAt: number | null,
): () => void {
usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt));
return host.onChange((here) => {
usePresence.setState((state) => presenceChange(state, here, now()));
});
}