(
+ namespace: string,
+ ...keys: K[]
+ ): { [P in K as CamelCase]: string } {
+ return Object.fromEntries(
+ keys.map(k => [toCamel(k), Events.getKey(namespace, k)])
+ ) as { [P in K as CamelCase
]: string };
+ }
+
+ static parseKey(key: string): { namespace: string; key: string } {
+ const index = key.indexOf('/');
+ if (index === -1) {
+ throw new Error(`Invalid event key: "${key}"`);
+ }
+ return {
+ namespace: key.slice(0, index),
+ key: key.slice(index + 1),
+ };
+ }
+
+ static dispatch(event: GameEvent): Pipe {
+ return pendingLens.over(pending => [...pending, event], []);
+ }
+
+ static handle(
+ type: string,
+ fn: (event: GameEvent) => Pipe
+ ): Pipe {
+ const { namespace, key } = Events.parseKey(type);
+ const isWildcard = key === '*';
+ const prefix = namespace + '/';
+
+ return (obj: GameFrame): GameFrame => {
+ const state = eventStateLens.get(obj);
+ const current = state?.current;
+ if (!current || current.length === 0) return obj;
+ let result: GameFrame = obj;
+ for (const event of current) {
+ if (isWildcard ? event.type.startsWith(prefix) : event.type === type) {
+ result = fn(event as GameEvent)(result);
+ }
+ }
+ return result;
+ };
+ }
+
+ /**
+ * Moves events from pending to current.
+ * This prevents events from being processed during the same frame they are created.
+ * This is important because pipes later in the pipeline may add new events.
+ */
+ static pipe: Pipe = Composer.over(events, ({ pending = [] }) => ({
+ pending: [],
+ current: pending,
+ }));
+}
diff --git a/src/engine/pipes/Perf.ts b/src/engine/pipes/Perf.ts
new file mode 100644
index 00000000..ea049425
--- /dev/null
+++ b/src/engine/pipes/Perf.ts
@@ -0,0 +1,171 @@
+import { Composer } from '../Composer';
+import { GameFrame, Pipe } from '../State';
+import { Events, type GameEvent } from './Events';
+import { pluginPaths, PluginId } from '../plugins/Plugins';
+import { typedPath } from '../Lens';
+import { sdk } from '../sdk';
+
+declare module '../sdk' {
+ interface SDK {
+ Perf: typeof Perf;
+ }
+}
+
+export type PluginPerfEntry = {
+ last: number;
+ avg: number;
+ max: number;
+ samples: number[];
+ lastTick: number;
+};
+
+export type PerfMetrics = Record>;
+
+export type PerfConfig = {
+ pluginBudget: number;
+};
+
+export type PerfContext = {
+ plugins: PerfMetrics;
+ config: PerfConfig;
+};
+
+const PLUGIN_NAMESPACE = 'core.perf';
+const SAMPLE_SIZE = 60;
+const EXPIRY_TICKS = 900;
+
+const DEFAULT_CONFIG: PerfConfig = {
+ pluginBudget: 1,
+};
+
+type OverBudgetPayload = {
+ id: string;
+ phase: string;
+ duration: number;
+ budget: number;
+};
+
+const eventType = Events.getKeys(PLUGIN_NAMESPACE, 'over_budget', 'configure');
+
+const perf = pluginPaths(PLUGIN_NAMESPACE);
+const frameTiming = typedPath([]);
+
+function isEntry(value: unknown): value is PluginPerfEntry {
+ return value != null && typeof value === 'object' && 'lastTick' in value;
+}
+
+function pruneExpired(
+ node: Record,
+ tick: number
+): [Record | undefined, boolean] {
+ let dirty = false;
+ const result: Record = {};
+
+ for (const [key, value] of Object.entries(node)) {
+ if (isEntry(value)) {
+ if (tick - value.lastTick <= EXPIRY_TICKS) {
+ result[key] = value;
+ } else {
+ dirty = true;
+ }
+ } else if (value && typeof value === 'object') {
+ const [pruned, changed] = pruneExpired(
+ value as Record,
+ tick
+ );
+ if (pruned) result[key] = pruned;
+ else dirty = true;
+ if (changed) dirty = true;
+ }
+ }
+
+ const empty = Object.keys(result).length === 0;
+ return [empty ? undefined : result, dirty];
+}
+
+export class Perf {
+ static withTiming(id: PluginId, phase: string, pluginPipe: Pipe): Pipe {
+ return Composer.do(({ get, set, pipe }) => {
+ if (!sdk.debug) {
+ pipe(pluginPipe);
+ return;
+ }
+
+ const before = performance.now();
+ pipe(pluginPipe);
+ const after = performance.now();
+ const duration = after - before;
+
+ const tick = get(frameTiming.tick) ?? 0;
+ const entryPath = perf.plugins[id][phase];
+ const entry = get(entryPath);
+
+ const samples = entry
+ ? [...entry.samples, duration].slice(-SAMPLE_SIZE)
+ : [duration];
+
+ const avg = samples.reduce((sum, v) => sum + v, 0) / samples.length;
+ const max = entry ? Math.max(entry.max, duration) : duration;
+
+ set(entryPath, { last: duration, avg, max, samples, lastTick: tick });
+
+ const budget =
+ get(perf.config.pluginBudget) ?? DEFAULT_CONFIG.pluginBudget;
+
+ if (duration > budget) {
+ console.warn(
+ `[perf] ${id} ${phase} took ${duration.toFixed(2)}ms (budget: ${budget}ms)`
+ );
+ pipe(
+ Events.dispatch({
+ type: eventType.overBudget,
+ payload: { id, phase, duration, budget },
+ })
+ );
+ }
+ });
+ }
+
+ static configure(config: Partial): Pipe {
+ return Events.dispatch({
+ type: eventType.configure,
+ payload: config,
+ });
+ }
+
+ static onOverBudget(fn: (event: GameEvent) => Pipe): Pipe {
+ return Events.handle(eventType.overBudget, fn);
+ }
+
+ static pipe: Pipe = Composer.pipe(
+ Composer.do(({ get, set }) => {
+ if (!get(perf.config)) {
+ set(perf.config, DEFAULT_CONFIG);
+ }
+ }),
+
+ Composer.do(({ get, set }) => {
+ const tick = get(frameTiming.tick) ?? 0;
+ const plugins = get(perf.plugins);
+ if (!plugins) return;
+
+ const [pruned, dirty] = pruneExpired(plugins, tick);
+ if (dirty) {
+ set(perf.plugins, pruned ?? {});
+ }
+ }),
+
+ Events.handle>(eventType.configure, event =>
+ Composer.over(perf.config, (config = DEFAULT_CONFIG) => ({
+ ...config,
+ ...event.payload,
+ }))
+ )
+ );
+
+ static get paths() {
+ return perf;
+ }
+}
+
+sdk.Perf = Perf;
diff --git a/src/engine/pipes/Scheduler.ts b/src/engine/pipes/Scheduler.ts
new file mode 100644
index 00000000..61dab9c3
--- /dev/null
+++ b/src/engine/pipes/Scheduler.ts
@@ -0,0 +1,148 @@
+import { Composer } from '../Composer';
+import { typedPath } from '../Lens';
+import { pluginPaths } from '../plugins/Plugins';
+import { GameTiming, Pipe } from '../State';
+import { Events, GameEvent } from './Events';
+
+const PLUGIN_NAMESPACE = 'core.scheduler';
+
+export type ScheduledEvent = {
+ id?: string;
+ duration: number;
+ event: GameEvent;
+ held?: boolean;
+};
+
+type SchedulerState = {
+ scheduled: ScheduledEvent[];
+ current: GameEvent[];
+};
+
+const scheduler = pluginPaths(PLUGIN_NAMESPACE);
+const timing = typedPath([]);
+
+const eventType = Events.getKeys(
+ PLUGIN_NAMESPACE,
+ 'schedule',
+ 'cancel',
+ 'hold',
+ 'release',
+ 'hold_by_prefix',
+ 'release_by_prefix',
+ 'cancel_by_prefix'
+);
+
+export class Scheduler {
+ static getKey(namespace: string, key: string): string {
+ return `${namespace}/schedule/${key}`;
+ }
+
+ // These API methods are events to ensure they dont have weird ordering problems.
+ // TODO: re-evaluate this decision
+
+ static schedule(event: ScheduledEvent): Pipe {
+ return Events.dispatch({ type: eventType.schedule, payload: event });
+ }
+
+ static cancel(id: string): Pipe {
+ return Events.dispatch({ type: eventType.cancel, payload: id });
+ }
+
+ static hold(id: string): Pipe {
+ return Events.dispatch({ type: eventType.hold, payload: id });
+ }
+
+ static release(id: string): Pipe {
+ return Events.dispatch({ type: eventType.release, payload: id });
+ }
+
+ static holdByPrefix(prefix: string): Pipe {
+ return Events.dispatch({ type: eventType.holdByPrefix, payload: prefix });
+ }
+
+ static releaseByPrefix(prefix: string): Pipe {
+ return Events.dispatch({
+ type: eventType.releaseByPrefix,
+ payload: prefix,
+ });
+ }
+
+ static cancelByPrefix(prefix: string): Pipe {
+ return Events.dispatch({ type: eventType.cancelByPrefix, payload: prefix });
+ }
+
+ static pipe: Pipe = Composer.pipe(
+ Composer.bind(timing.step, delta =>
+ Composer.over(scheduler, ({ scheduled = [] }) => {
+ const remaining: ScheduledEvent[] = [];
+ const current: GameEvent[] = [];
+
+ for (const entry of scheduled) {
+ if (entry.held) {
+ remaining.push(entry);
+ continue;
+ }
+ const time = entry.duration - delta;
+ if (time <= 0) {
+ current.push(entry.event);
+ } else {
+ remaining.push({ ...entry, duration: time });
+ }
+ }
+
+ return { scheduled: remaining, current };
+ })
+ ),
+
+ Composer.bind(scheduler.current, events =>
+ Composer.pipe(...events.map(Events.dispatch))
+ ),
+
+ Events.handle(eventType.schedule, event =>
+ Composer.over(scheduler.scheduled, list => [
+ ...list.filter(e => e.id !== event.payload.id),
+ event.payload,
+ ])
+ ),
+
+ Events.handle(eventType.cancel, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.filter(s => s.id !== event.payload)
+ )
+ ),
+
+ Events.handle(eventType.hold, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.map(s => (s.id === event.payload ? { ...s, held: true } : s))
+ )
+ ),
+
+ Events.handle(eventType.release, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.map(s => (s.id === event.payload ? { ...s, held: false } : s))
+ )
+ ),
+
+ Events.handle(eventType.holdByPrefix, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.map(s =>
+ s.id?.startsWith(event.payload) ? { ...s, held: true } : s
+ )
+ )
+ ),
+
+ Events.handle(eventType.releaseByPrefix, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.map(s =>
+ s.id?.startsWith(event.payload) ? { ...s, held: false } : s
+ )
+ )
+ ),
+
+ Events.handle(eventType.cancelByPrefix, event =>
+ Composer.over(scheduler.scheduled, list =>
+ list.filter(s => !s.id?.startsWith(event.payload))
+ )
+ )
+ );
+}
diff --git a/src/engine/pipes/Storage.ts b/src/engine/pipes/Storage.ts
new file mode 100644
index 00000000..12c75668
--- /dev/null
+++ b/src/engine/pipes/Storage.ts
@@ -0,0 +1,146 @@
+/**
+ * Storage Pipe
+ *
+ * Primitive pipe that provides access to localStorage.
+ * Uses lazy loading with time-based cache (hot settings).
+ *
+ * Architecture:
+ * - Settings are loaded on-demand from localStorage
+ * - Cached in context with expiry time (measured in game time)
+ * - Hot cache expires after inactivity to save memory
+ * - Writes are immediate to localStorage and update cache
+ */
+
+import { Composer } from '../Composer';
+import { typedPath } from '../Lens';
+import { pluginPaths } from '../plugins/Plugins';
+import { GameTiming, Pipe } from '../State';
+
+export const STORAGE_NAMESPACE = 'how.joi.storage';
+const CACHE_TTL = 30000; // 30 seconds of game time
+
+export type CacheEntry = {
+ value: any;
+ expiry: number; // game time when this expires
+};
+
+export type StorageContext = {
+ cache: { [key: string]: CacheEntry };
+};
+
+const storage = pluginPaths(STORAGE_NAMESPACE);
+const timing = typedPath([]);
+
+/**
+ * Storage API for reading and writing to localStorage
+ */
+export class Storage {
+ /**
+ * Loads a value from localStorage
+ */
+ private static load(key: string): T | undefined {
+ try {
+ const value = localStorage.getItem(key);
+ if (value !== null) {
+ return JSON.parse(value) as T;
+ }
+ } catch (e) {
+ console.error(`Failed to load from localStorage key "${key}":`, e);
+ }
+ return undefined;
+ }
+
+ /**
+ * Gets a value, using cache or loading from localStorage
+ */
+ static bind(key: string, fn: (value: T | undefined) => Pipe): Pipe {
+ return Composer.bind(storage, ctx => {
+ const cache = ctx?.cache || {};
+ const cached = cache[key];
+
+ if (cached) {
+ return fn(cached.value as T | undefined);
+ }
+
+ const value = Storage.load(key);
+
+ return Composer.pipe(
+ Composer.bind(timing.time, elapsedTime =>
+ Composer.over(storage, ctx => ({
+ cache: {
+ ...(ctx?.cache || {}),
+ [key]: {
+ value,
+ expiry: elapsedTime + CACHE_TTL,
+ },
+ },
+ }))
+ ),
+ fn(value)
+ );
+ });
+ }
+
+ /**
+ * Sets a value in localStorage and updates cache
+ */
+ static set(key: string, value: T): Pipe {
+ return frame => {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (e) {
+ console.error('Failed to write to localStorage:', e);
+ }
+
+ return Composer.bind(timing.time, elapsedTime =>
+ Composer.over(storage, ctx => ({
+ cache: {
+ ...(ctx?.cache || {}),
+ [key]: {
+ value,
+ expiry: elapsedTime + CACHE_TTL,
+ },
+ },
+ }))
+ )(frame);
+ };
+ }
+
+ /**
+ * Removes a value from localStorage and cache
+ */
+ static remove(key: string): Pipe {
+ return frame => {
+ try {
+ localStorage.removeItem(key);
+ } catch (e) {
+ console.error('Failed to remove from localStorage:', e);
+ }
+
+ return Composer.over(storage, ctx => {
+ const newCache = { ...(ctx?.cache || {}) };
+ delete newCache[key];
+ return { cache: newCache };
+ })(frame);
+ };
+ }
+
+ /**
+ * Storage pipe - evicts expired cache entries
+ */
+ static pipe: Pipe = Composer.pipe(
+ Composer.bind(timing.time, elapsedTime =>
+ Composer.over(storage, ctx => {
+ const newCache: { [key: string]: CacheEntry } = {};
+
+ for (const [key, entry] of Object.entries(ctx?.cache || {})) {
+ if (entry.expiry > elapsedTime) {
+ newCache[key] = entry;
+ }
+ }
+
+ return { cache: newCache };
+ })
+ )
+ );
+}
diff --git a/src/engine/pipes/index.ts b/src/engine/pipes/index.ts
new file mode 100644
index 00000000..3bf0322e
--- /dev/null
+++ b/src/engine/pipes/index.ts
@@ -0,0 +1,8 @@
+export * from './Events';
+export * from '../plugins/PluginInstaller';
+export * from '../plugins/PluginManager';
+export * from '../plugins/Plugins';
+export * from './Scheduler';
+export * from './Storage';
+export * from './Perf';
+export * from './Errors';
diff --git a/src/engine/plugins/PluginInstaller.ts b/src/engine/plugins/PluginInstaller.ts
new file mode 100644
index 00000000..9c498f07
--- /dev/null
+++ b/src/engine/plugins/PluginInstaller.ts
@@ -0,0 +1,141 @@
+import { Composer } from '../Composer';
+import { Pipe } from '../State';
+import { Storage } from '../pipes/Storage';
+import { PluginManager } from './PluginManager';
+import { pluginPaths, type PluginId, type PluginClass } from './Plugins';
+
+const PLUGIN_NAMESPACE = 'core.plugin_installer';
+
+type PluginLoad = {
+ promise: Promise;
+ result?: PluginClass;
+ error?: Error;
+};
+
+type InstallerState = {
+ installed: PluginId[];
+ failed: PluginId[];
+ pending: Map;
+};
+
+const ins = pluginPaths(PLUGIN_NAMESPACE);
+
+const storageKey = {
+ user: `${PLUGIN_NAMESPACE}.user`,
+ code: (id: PluginId) => `${PLUGIN_NAMESPACE}.code/${id}`,
+};
+
+async function load(code: string): Promise {
+ const blob = new Blob([code], { type: 'text/javascript' });
+ const url = URL.createObjectURL(blob);
+
+ try {
+ const module = await import(/* @vite-ignore */ url);
+ const cls = module.default;
+
+ if (!cls?.plugin?.id) {
+ throw new Error(
+ 'Plugin must export a default class with a static plugin field'
+ );
+ }
+
+ return cls;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+const importPipe: Pipe = Storage.bind(
+ storageKey.user,
+ (userPluginIds = []) =>
+ Composer.pipe(
+ ...userPluginIds.map(id =>
+ Storage.bind(storageKey.code(id), code =>
+ Composer.do(({ get, over }) => {
+ const installed = get(ins.installed) ?? [];
+ const failed = get(ins.failed) ?? [];
+ const pending = get(ins.pending);
+
+ if (
+ installed.includes(id) ||
+ failed.includes(id) ||
+ pending?.has(id)
+ )
+ return;
+
+ if (!code) {
+ console.error(
+ `[PluginInstaller] plugin "${id}" has no code in storage`
+ );
+ over(ins.failed, (ids = []) => [
+ ...(Array.isArray(ids) ? ids : []),
+ id,
+ ]);
+ return;
+ }
+
+ over(ins.pending, pending => {
+ if (!(pending instanceof Map)) pending = new Map();
+ // TODO: generic async resolver pipe?
+ const pluginLoad: PluginLoad = {
+ promise: load(code),
+ };
+ pluginLoad.promise.then(
+ plugin => {
+ pluginLoad.result = plugin;
+ },
+ error => {
+ pluginLoad.error = error;
+ }
+ );
+ return new Map([...pending, [id, pluginLoad]]);
+ });
+ })
+ )
+ )
+ )
+);
+
+const resolvePipe: Pipe = Composer.do(({ get, set, over, pipe }) => {
+ const pending = get(ins.pending);
+ if (!pending?.size) return;
+
+ const resolved: PluginClass[] = [];
+ const failed: PluginId[] = [];
+ const remaining = new Map();
+
+ for (const [id, entry] of pending) {
+ if (entry.result) {
+ resolved.push(entry.result);
+ } else if (entry.error) {
+ console.error(
+ `[PluginInstaller] failed to load plugin "${id}":`,
+ entry.error
+ );
+ failed.push(id);
+ } else {
+ remaining.set(id, entry);
+ }
+ }
+
+ if (resolved.length > 0) {
+ pipe(...resolved.map(PluginManager.register));
+ over(ins.installed, (ids = []) => [
+ ...(Array.isArray(ids) ? ids : []),
+ ...resolved.map(cls => cls.plugin.id),
+ ]);
+ }
+
+ if (failed.length > 0) {
+ over(ins.failed, (ids = []) => [
+ ...(Array.isArray(ids) ? ids : []),
+ ...failed,
+ ]);
+ }
+
+ if (remaining.size !== pending.size) {
+ set(ins.pending, remaining);
+ }
+});
+
+export const pluginInstallerPipe: Pipe = Composer.pipe(importPipe, resolvePipe);
diff --git a/src/engine/plugins/PluginManager.ts b/src/engine/plugins/PluginManager.ts
new file mode 100644
index 00000000..cfdfe94b
--- /dev/null
+++ b/src/engine/plugins/PluginManager.ts
@@ -0,0 +1,283 @@
+import { Composer } from '../Composer';
+import { Pipe, PipeTransformer } from '../State';
+import {
+ startDOMBatching,
+ stopDOMBatching,
+ flushDOMOperations,
+} from '../DOMBatcher';
+import { Storage } from '../pipes/Storage';
+import { Events } from '../pipes/Events';
+import {
+ pluginPaths,
+ type PluginId,
+ type PluginClass,
+ type PluginRegistry,
+ type EnabledMap,
+} from './Plugins';
+import { Perf } from '../pipes/Perf';
+import { Errors } from '../pipes/Errors';
+import { sdk } from '../sdk';
+
+const PLUGIN_NAMESPACE = 'core.plugin_manager';
+
+const eventType = Events.getKeys(
+ PLUGIN_NAMESPACE,
+ 'register',
+ 'unregister',
+ 'enable',
+ 'disable'
+);
+
+const storageKey = {
+ enabled: `${PLUGIN_NAMESPACE}.enabled`,
+};
+
+export type PluginManagerAPI = {
+ register: PipeTransformer<[PluginClass]>;
+ unregister: PipeTransformer<[PluginId]>;
+ enable: PipeTransformer<[PluginId]>;
+ disable: PipeTransformer<[PluginId]>;
+};
+
+type PluginManagerState = PluginManagerAPI & {
+ loaded: PluginId[];
+ registry: PluginRegistry;
+ loadedRefs: Record;
+ toLoad: PluginId[];
+ toUnload: PluginId[];
+};
+
+const pm = pluginPaths(PLUGIN_NAMESPACE);
+
+export class PluginManager {
+ static register(pluginClass: PluginClass): Pipe {
+ return Composer.bind(pm, ({ register }) => register(pluginClass));
+ }
+
+ static unregister(id: PluginId): Pipe {
+ return Composer.bind(pm, ({ unregister }) => unregister(id));
+ }
+
+ static enable(id: PluginId): Pipe {
+ return Composer.bind(pm, ({ enable }) => enable(id));
+ }
+
+ static disable(id: PluginId): Pipe {
+ return Composer.bind(pm, ({ disable }) => disable(id));
+ }
+}
+
+const apiPipe: Pipe = Composer.over(pm, ctx => ({
+ ...ctx,
+
+ register: plugin =>
+ Events.dispatch({
+ type: eventType.register,
+ payload: plugin,
+ }),
+
+ unregister: id =>
+ Events.dispatch({
+ type: eventType.unregister,
+ payload: id,
+ }),
+
+ enable: id =>
+ Events.dispatch({
+ type: eventType.enable,
+ payload: id,
+ }),
+
+ disable: id =>
+ Events.dispatch({
+ type: eventType.disable,
+ payload: id,
+ }),
+}));
+
+// TODO: enable/disable plugin storage should probably live elsewhere.
+const enableDisablePipe: Pipe = Composer.pipe(
+ Events.handle(eventType.enable, event =>
+ Storage.bind(storageKey.enabled, (map = {}) =>
+ Storage.set(storageKey.enabled, {
+ ...map,
+ [event.payload]: true,
+ })
+ )
+ ),
+ Events.handle(eventType.disable, event =>
+ Storage.bind(storageKey.enabled, (map = {}) =>
+ Storage.set(storageKey.enabled, {
+ ...map,
+ [event.payload]: false,
+ })
+ )
+ )
+);
+
+const reconcilePipe: Pipe = Composer.pipe(
+ Events.handle(eventType.register, event =>
+ Composer.do(({ over }) => {
+ over(pm.registry, registry => ({
+ ...registry,
+ [event.payload.plugin.id]: event.payload,
+ }));
+ })
+ ),
+ Events.handle(eventType.unregister, event =>
+ Composer.do(({ over }) => {
+ over(pm.toUnload, (ids = []) =>
+ Array.isArray(ids) ? [...ids, event.payload] : [event.payload]
+ );
+ })
+ ),
+ Storage.bind(storageKey.enabled, (stored = {}) =>
+ Composer.do(({ get, set, pipe }) => {
+ const registry = get(pm.registry) ?? {};
+ const loaded = get(pm.loaded) ?? [];
+ const forcedUnload = get(pm.toUnload) ?? [];
+
+ const map = { ...stored };
+ let dirty = false;
+
+ for (const id of Object.keys(registry)) {
+ if (!(id in map)) {
+ map[id] = true;
+ dirty = true;
+ }
+ }
+
+ const shouldBeLoaded = new Set(
+ Object.keys(map).filter(id => map[id] && registry[id])
+ );
+
+ for (const id of forcedUnload) shouldBeLoaded.delete(id);
+
+ const currentlyLoaded = new Set(loaded);
+
+ const toUnload = [...currentlyLoaded].filter(
+ id => !shouldBeLoaded.has(id)
+ );
+
+ const toLoad = [...shouldBeLoaded].filter(id => !currentlyLoaded.has(id));
+
+ if (!dirty && toLoad.length === 0 && toUnload.length === 0) return;
+
+ if (dirty) pipe(Storage.set(storageKey.enabled, map));
+ if (toLoad.length > 0) set(pm.toLoad, toLoad);
+ if (toUnload.length > 0) set(pm.toUnload, toUnload);
+ })
+ )
+);
+
+const lifecyclePipe: Pipe = Composer.do(({ get, pipe }) => {
+ const toUnload = get(pm.toUnload) ?? [];
+ const toLoad = get(pm.toLoad) ?? [];
+ const loadedRefs = get(pm.loadedRefs) ?? {};
+ const registry = get(pm.registry) ?? {};
+
+ for (const id of toUnload) {
+ const cls = loadedRefs[id] ?? registry[id];
+ if (cls) delete (sdk as any)[cls.name];
+ }
+
+ for (const id of toLoad) {
+ const cls = registry[id];
+ if (cls) (sdk as any)[cls.name] = cls;
+ }
+
+ const deactivates = toUnload
+ .map(id => {
+ const p = (loadedRefs[id] ?? registry[id])?.plugin.deactivate;
+ return p
+ ? Perf.withTiming(
+ id,
+ 'deactivate',
+ Errors.withCatch(id, 'deactivate', p)
+ )
+ : undefined;
+ })
+ .filter(Boolean) as Pipe[];
+
+ const activates = toLoad
+ .map(id => {
+ const p = registry[id]?.plugin.activate;
+ return p
+ ? Perf.withTiming(id, 'activate', Errors.withCatch(id, 'activate', p))
+ : undefined;
+ })
+ .filter(Boolean) as Pipe[];
+
+ const activeIds = [
+ ...Object.keys(loadedRefs).filter(id => !toUnload.includes(id)),
+ ...toLoad,
+ ];
+
+ const updates = activeIds
+ .map(id => {
+ const p = (loadedRefs[id] ?? registry[id])?.plugin.update;
+ return p
+ ? Perf.withTiming(id, 'update', Errors.withCatch(id, 'update', p))
+ : undefined;
+ })
+ .filter(Boolean) as Pipe[];
+
+ const pipes = [...deactivates, ...activates, ...updates];
+ if (pipes.length === 0) return;
+
+ startDOMBatching();
+ pipe(...pipes);
+ stopDOMBatching();
+ flushDOMOperations();
+});
+
+const finalizePipe: Pipe = Composer.pipe(
+ Events.handle(eventType.unregister, event =>
+ Composer.do(({ over }) => {
+ over(pm.registry, registry => {
+ const next = { ...registry };
+ delete next[event.payload];
+ return next;
+ });
+ })
+ ),
+ Composer.do(({ get, set, over }) => {
+ const toUnload = get(pm.toUnload) ?? [];
+ const toLoad = get(pm.toLoad) ?? [];
+
+ if (toLoad.length === 0 && toUnload.length === 0) return;
+
+ const loadedRefs = get(pm.loadedRefs) ?? {};
+ const registry = get(pm.registry) ?? {};
+
+ const newRefs = { ...loadedRefs };
+ for (const id of toUnload) delete newRefs[id];
+ for (const id of toLoad) {
+ if (registry[id]) newRefs[id] = registry[id];
+ }
+
+ set(pm.loaded, Object.keys(newRefs));
+ over(pm, ctx => ({
+ ...ctx,
+ loadedRefs: newRefs,
+ toLoad: [],
+ toUnload: [],
+ }));
+ })
+);
+
+declare module '../sdk' {
+ interface SDK {
+ PluginManager: typeof PluginManager;
+ }
+}
+
+sdk.PluginManager = PluginManager;
+
+export const pluginManagerPipe: Pipe = Composer.pipe(
+ apiPipe,
+ enableDisablePipe,
+ reconcilePipe,
+ lifecyclePipe,
+ finalizePipe
+);
diff --git a/src/engine/plugins/Plugins.ts b/src/engine/plugins/Plugins.ts
new file mode 100644
index 00000000..58b4a11b
--- /dev/null
+++ b/src/engine/plugins/Plugins.ts
@@ -0,0 +1,31 @@
+import { typedPath, TypedPath } from '../Lens';
+import { Pipe } from '../State';
+
+export function pluginPaths(namespace: string): TypedPath {
+ return typedPath([namespace]);
+}
+
+export type PluginId = string;
+
+export type PluginMeta = {
+ name?: string;
+ description?: string;
+ version?: string;
+ author?: string;
+};
+
+export type Plugin = {
+ id: PluginId;
+ meta?: PluginMeta;
+ activate?: Pipe;
+ update?: Pipe;
+ deactivate?: Pipe;
+};
+
+export type PluginClass = {
+ plugin: Plugin;
+ name: string;
+};
+
+export type PluginRegistry = Record;
+export type EnabledMap = Record;
diff --git a/src/engine/sdk.ts b/src/engine/sdk.ts
new file mode 100644
index 00000000..e11db92e
--- /dev/null
+++ b/src/engine/sdk.ts
@@ -0,0 +1,28 @@
+import { Composer } from './Composer';
+import { Events } from './pipes/Events';
+import { Scheduler } from './pipes/Scheduler';
+import { Storage } from './pipes/Storage';
+import { pluginPaths } from './plugins/Plugins';
+
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+export interface PluginSDK {}
+
+export interface SDK extends PluginSDK {
+ debug: boolean;
+ Composer: typeof Composer;
+ Events: typeof Events;
+ Scheduler: typeof Scheduler;
+ Storage: typeof Storage;
+ pluginPaths: typeof pluginPaths;
+}
+
+export const sdk: SDK = {
+ debug: false,
+ Composer,
+ Events,
+ Scheduler,
+ Storage,
+ pluginPaths,
+} as SDK;
+
+(globalThis as any).sdk = sdk;
diff --git a/src/game/GamePage.tsx b/src/game/GamePage.tsx
index 3a245977..938d26ab 100644
--- a/src/game/GamePage.tsx
+++ b/src/game/GamePage.tsx
@@ -1,20 +1,14 @@
import styled from 'styled-components';
-import {
- GameHypno,
- GameImages,
- GameMeter,
- GameIntensity,
- GameSound,
- GameInstructions,
- GamePace,
- GameEvents,
- GameMessages,
- GameWarmup,
- GameEmergencyStop,
- GameSettings,
- GameVibrator,
-} from './components';
-import { GameProvider } from './GameProvider';
+import { GameMessages } from './components/GameMessages';
+import { GameImages } from './components/GameImages';
+import { GameMeter } from './components/GameMeter';
+import { GameHypno } from './components/GameHypno';
+import { GameSound } from './components/GameSound';
+import { GameVibrator } from './components/GameVibrator';
+import { GameInstructions } from './components/GameInstructions';
+import { GameEmergencyStop } from './components/GameEmergencyStop';
+import { GamePauseMenu } from './components/GamePauseMenu';
+import { GameResume } from './components/GameResume';
const StyledGamePage = styled.div`
position: relative;
@@ -28,10 +22,6 @@ const StyledGamePage = styled.div`
align-items: center;
`;
-const StyledLogicElements = styled.div`
- // these elements have no visual representation. This style is merely to group them.
-`;
-
const StyledTopBar = styled.div`
position: absolute;
top: 0;
@@ -82,30 +72,23 @@ const StyledBottomBar = styled.div`
export const GamePage = () => {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/game/GameProvider.tsx b/src/game/GameProvider.tsx
index dedd60db..58c848d5 100644
--- a/src/game/GameProvider.tsx
+++ b/src/game/GameProvider.tsx
@@ -1,100 +1,124 @@
-import { useCallback } from 'react';
-import { createStateProvider } from '../utils';
-import { ImageItem } from '../types';
-
-export enum Paws {
- left = 'left',
- right = 'right',
- both = 'both',
- none = 'none',
-}
+import { useEffect, useRef, useState, ReactNode, useCallback } from 'react';
+import { createContext } from 'use-context-selector';
+import { GameEngine, Pipe, GameFrame } from '../engine';
+import { Events } from '../engine/pipes/Events';
+import { Scheduler } from '../engine/pipes/Scheduler';
+import { Perf } from '../engine/pipes/Perf';
+import { Errors } from '../engine/pipes/Errors';
+import { Piper } from '../engine/Piper';
+import { Composer } from '../engine/Composer';
-export const PawLabels: Record = {
- [Paws.left]: 'Left',
- [Paws.right]: 'Right',
- [Paws.both]: 'Both',
- [Paws.none]: 'Off',
+type GameEngineContextValue = {
+ /**
+ * The current game frame containing all plugin data and timing.
+ */
+ frame: GameFrame | null;
+ /**
+ * Queue a one-shot pipe to run in the next tick only.
+ */
+ injectImpulse: (pipe: Pipe) => void;
};
-export enum Stroke {
- up = 'up',
- down = 'down',
-}
+// eslint-disable-next-line react-refresh/only-export-components
+export const GameEngineContext = createContext<
+ GameEngineContextValue | undefined
+>(undefined);
-export enum GamePhase {
- pause = 'pause',
- warmup = 'warmup',
- active = 'active',
- break = 'break',
- finale = 'finale',
- climax = 'climax',
-}
+type Props = {
+ children: ReactNode;
+ pipes?: Pipe[];
+};
-export interface GameMessagePrompt {
- title: string;
- onClick: () => void | Promise;
-}
+export function GameEngineProvider({ children, pipes = [] }: Props) {
+ const engineRef = useRef(null);
-export interface GameMessage {
- id: string;
- title: string;
- description?: string;
- prompts?: GameMessagePrompt[];
- duration?: number;
-}
+ const [frame, setFrame] = useState(null);
-export interface GameState {
- pace: number;
- intensity: number;
- currentImage?: ImageItem;
- seenImages: ImageItem[];
- nextImages: ImageItem[];
- currentHypno: number;
- paws: Paws;
- stroke: Stroke;
- phase: GamePhase;
- edged: boolean;
- messages: GameMessage[];
-}
+ const pendingImpulseRef = useRef([]);
+ const activeImpulseRef = useRef([]);
-export const initialGameState: GameState = {
- pace: 0,
- intensity: 0,
- currentImage: undefined,
- seenImages: [],
- nextImages: [],
- currentHypno: 0,
- paws: Paws.none,
- stroke: Stroke.down,
- phase: GamePhase.warmup,
- edged: false,
- messages: [],
-};
+ useEffect(() => {
+ // To inject one-shot pipes (impulses) into the engine,
+ // we use the pending ref to stage them, and the active ref to apply them.
+ const impulsePipe: Pipe = Composer.chain(c =>
+ c.pipe(...activeImpulseRef.current)
+ );
+
+ engineRef.current = new GameEngine(
+ {},
+ Piper([
+ impulsePipe,
+ Events.pipe,
+ Scheduler.pipe,
+ Perf.pipe,
+ Errors.pipe,
+ ...pipes,
+ ])
+ );
+
+ const STEP = 16;
+ const MAX_TICKS_PER_FRAME = 4;
+ let accumulator = 0;
+ let lastWallTime: number | null = null;
+ let frameId: number;
+
+ const loop = () => {
+ if (!engineRef.current) return;
+
+ const now = performance.now();
+
+ if (document.hidden || lastWallTime === null) {
+ lastWallTime = now;
+ frameId = requestAnimationFrame(loop);
+ return;
+ }
-export const {
- Provider: GameProvider,
- useProvider: useGame,
- useProviderSelector: useGameValue,
-} = createStateProvider({
- defaultData: initialGameState,
-});
-
-export const useSendMessage = () => {
- const [, setMessages] = useGameValue('messages');
-
- return useCallback(
- (message: Partial & { id: string }) => {
- setMessages(messages => {
- const previous = messages.find(m => m.id === message.id);
- return [
- ...messages.filter(m => m.id !== message.id),
- {
- ...previous,
- ...message,
- } as GameMessage,
- ];
- });
- },
- [setMessages]
+ accumulator += now - lastWallTime;
+ lastWallTime = now;
+
+ let ticked = false;
+ let ticks = 0;
+
+ while (accumulator >= STEP && ticks < MAX_TICKS_PER_FRAME) {
+ if (!ticked) {
+ activeImpulseRef.current = pendingImpulseRef.current;
+ pendingImpulseRef.current = [];
+ ticked = true;
+ }
+
+ engineRef.current.tick();
+ accumulator -= STEP;
+ ticks++;
+ }
+
+ if (ticks >= MAX_TICKS_PER_FRAME) {
+ accumulator = 0;
+ }
+
+ if (ticked) {
+ setFrame(engineRef.current.getFrame());
+ }
+
+ frameId = requestAnimationFrame(loop);
+ };
+
+ frameId = requestAnimationFrame(loop);
+
+ return () => {
+ cancelAnimationFrame(frameId);
+ engineRef.current = null;
+ pendingImpulseRef.current = [];
+ activeImpulseRef.current = [];
+ };
+ }, [pipes]);
+
+ const injectImpulse = useCallback((pipe: Pipe) => {
+ pendingImpulseRef.current.push(pipe);
+ }, []);
+
+ return (
+
+ {children}
+
);
-};
+}
diff --git a/src/game/GameShell.tsx b/src/game/GameShell.tsx
new file mode 100644
index 00000000..5e03d621
--- /dev/null
+++ b/src/game/GameShell.tsx
@@ -0,0 +1,25 @@
+import { useMemo, ReactNode } from 'react';
+import { GameEngineProvider } from './GameProvider';
+import { useSettingsPipe } from './pipes';
+import { pluginInstallerPipe } from '../engine/plugins/PluginInstaller';
+import { pluginManagerPipe } from '../engine/plugins/PluginManager';
+import { registerPlugins } from './plugins';
+
+type Props = {
+ children: ReactNode;
+};
+
+export const GameShell = ({ children }: Props) => {
+ const settingsPipe = useSettingsPipe();
+ const pipes = useMemo(
+ () => [
+ pluginManagerPipe,
+ pluginInstallerPipe,
+ registerPlugins,
+ settingsPipe,
+ ],
+ [settingsPipe]
+ );
+
+ return {children};
+};
diff --git a/src/game/SceneBridge.tsx b/src/game/SceneBridge.tsx
new file mode 100644
index 00000000..906fb865
--- /dev/null
+++ b/src/game/SceneBridge.tsx
@@ -0,0 +1,48 @@
+import { useEffect, useRef } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useGameEngine } from './hooks/UseGameEngine';
+import { useGameFrame } from './hooks';
+import Scene from './plugins/scene';
+
+const routeToScene: Record = {
+ '/': 'home',
+ '/play': 'game',
+ '/end': 'end',
+};
+
+const sceneToRoute: Record = {
+ home: '/',
+ game: '/play',
+ end: '/end',
+};
+
+export const SceneBridge = () => {
+ const { injectImpulse } = useGameEngine();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const sceneState = useGameFrame(Scene.paths) as
+ | { current?: string }
+ | undefined;
+
+ const lastRouteSceneRef = useRef(null);
+
+ useEffect(() => {
+ const scene = routeToScene[location.pathname] ?? 'unknown';
+ lastRouteSceneRef.current = scene;
+ injectImpulse(Scene.setScene(scene));
+ }, [location.pathname, injectImpulse]);
+
+ useEffect(() => {
+ const current = sceneState?.current;
+ if (!current || current === 'unknown') return;
+ if (current === lastRouteSceneRef.current) return;
+
+ const route = sceneToRoute[current];
+ if (!route) return;
+
+ lastRouteSceneRef.current = current;
+ navigate(route);
+ }, [navigate, sceneState]);
+
+ return null;
+};
diff --git a/src/game/Sequence.ts b/src/game/Sequence.ts
new file mode 100644
index 00000000..e4a68d81
--- /dev/null
+++ b/src/game/Sequence.ts
@@ -0,0 +1,76 @@
+import { Pipe } from '../engine/State';
+import { Events, GameEvent, Scheduler } from '../engine/pipes';
+import { sdk } from '../engine/sdk';
+import Messages from './plugins/messages';
+
+type MessageInput = Omit[0], 'id'>;
+type MessagePrompt = NonNullable[number];
+
+export type SequenceScope = {
+ messageId: string;
+ on(handler: (event: GameEvent) => Pipe): Pipe;
+ on(name: string, handler: (event: GameEvent) => Pipe): Pipe;
+ message(msg: MessageInput): Pipe;
+ after(duration: number, target: string, payload?: T): Pipe;
+ prompt(title: string, target: string, payload?: T): MessagePrompt;
+ start(payload?: T): Pipe;
+ cancel(): Pipe;
+ dispatch(target: string, payload?: T): Pipe;
+ eventKey(target: string): string;
+ scheduleKey(target: string): string;
+};
+
+export class Sequence {
+ static for(namespace: string, name: string): SequenceScope {
+ const rootKey = Events.getKey(namespace, name);
+ const nodeKey = (n: string) => Events.getKey(namespace, `${name}.${n}`);
+ const schedKey = (n: string) => Scheduler.getKey(namespace, `${name}.${n}`);
+
+ return {
+ messageId: name,
+ on(nameOrHandler: any, handler?: any) {
+ if (typeof nameOrHandler === 'function')
+ return Events.handle(rootKey, nameOrHandler);
+ return Events.handle(nodeKey(nameOrHandler), handler);
+ },
+ message: msg => Messages.send({ ...msg, id: name }),
+ after: (duration, target, payload) =>
+ Scheduler.schedule({
+ id: schedKey(target),
+ duration,
+ event: {
+ type: nodeKey(target),
+ ...(payload !== undefined && { payload }),
+ },
+ }),
+ prompt: (title, target, payload) => ({
+ title,
+ event: {
+ type: nodeKey(target),
+ ...(payload !== undefined && { payload }),
+ },
+ }),
+ start: payload =>
+ Events.dispatch({
+ type: rootKey,
+ ...(payload !== undefined && { payload }),
+ } as GameEvent),
+ cancel: () => Scheduler.cancelByPrefix(Scheduler.getKey(namespace, name)),
+ dispatch: (target, payload) =>
+ Events.dispatch({
+ type: target ? nodeKey(target) : rootKey,
+ ...(payload !== undefined && { payload }),
+ } as GameEvent),
+ eventKey: nodeKey,
+ scheduleKey: schedKey,
+ };
+ }
+}
+
+declare module '../engine/sdk' {
+ interface SDK {
+ Sequence: typeof Sequence;
+ }
+}
+
+sdk.Sequence = Sequence;
diff --git a/src/game/components/GameEmergencyStop.tsx b/src/game/components/GameEmergencyStop.tsx
index fa0d9747..defd8f10 100644
--- a/src/game/components/GameEmergencyStop.tsx
+++ b/src/game/components/GameEmergencyStop.tsx
@@ -1,52 +1,17 @@
-import { GamePhase, useGameValue, useSendMessage } from '../GameProvider';
import { useCallback } from 'react';
-import { wait } from '../../utils';
-import { useSetting } from '../../settings';
import { WaButton, WaIcon } from '@awesome.me/webawesome/dist/react';
+import { useGameFrame } from '../hooks';
+import { useDispatchEvent } from '../hooks/UseDispatchEvent';
+import Phase, { GamePhase } from '../plugins/phase';
+import { Events } from '../../engine/pipes/Events';
export const GameEmergencyStop = () => {
- const [phase, setPhase] = useGameValue('phase');
- const [intensity, setIntensity] = useGameValue('intensity');
- const [, setPace] = useGameValue('pace');
- const [minPace] = useSetting('minPace');
- const sendMessage = useSendMessage();
- const messageId = 'emergency-stop';
+ const phase = useGameFrame(Phase.paths.current) ?? '';
+ const { dispatchEvent } = useDispatchEvent();
- const onStop = useCallback(async () => {
- const timeToCalmDown = Math.ceil((intensity * 500 + 10000) / 1000);
-
- setPhase(GamePhase.break);
-
- sendMessage({
- id: messageId,
- title: 'Calm down with your $hands off.',
- });
-
- // maybe percentage based reduction
- setIntensity(intensity => Math.max(intensity - 30, 0));
- setPace(minPace);
-
- await wait(5000);
-
- for (let i = 0; i < timeToCalmDown; i++) {
- sendMessage({
- id: messageId,
- description: `${timeToCalmDown - i}...`,
- });
- await wait(1000);
- }
-
- sendMessage({
- id: messageId,
- title: 'Put your $hands back.',
- description: undefined,
- duration: 5000,
- });
-
- await wait(2000);
-
- setPhase(GamePhase.active);
- }, [intensity, minPace, sendMessage, setIntensity, setPace, setPhase]);
+ const onStop = useCallback(() => {
+ dispatchEvent({ type: Events.getKey('core.emergencyStop', 'stop') });
+ }, [dispatchEvent]);
return (
<>
diff --git a/src/game/components/GameEvents.tsx b/src/game/components/GameEvents.tsx
deleted file mode 100644
index 4150cb54..00000000
--- a/src/game/components/GameEvents.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-/* eslint-disable react-refresh/only-export-components */
-import { MutableRefObject, useEffect } from 'react';
-import { GameEvent } from '../../types';
-import {
- GamePhase,
- GameState,
- useGame,
- useGameValue,
- useSendMessage,
-} from '../GameProvider';
-import { Settings, useSetting, useSettings } from '../../settings';
-import {
- useLooping,
- useAutoRef,
- createStateSetters,
- StateWithSetters,
-} from '../../utils';
-import {
- cleanUpEvent,
- climaxEvent,
- doublePaceEvent,
- edgeEvent,
- halfPaceEvent,
- pauseEvent,
- randomGripEvent,
- randomPaceEvent,
- risingPaceEvent,
-} from './events';
-
-export interface EventData {
- game: StateWithSetters & {
- sendMessage: ReturnType;
- };
- settings: StateWithSetters;
-}
-
-export type EventDataRef = MutableRefObject;
-
-export const rollEventDice = (data: EventDataRef) => {
- const {
- game: { intensity, phase, edged },
- settings: { events },
- } = data.current;
-
- const roll = (chance: number): boolean =>
- Math.floor(Math.random() * chance) === 0;
-
- if (phase !== GamePhase.active) return null;
-
- if (
- events.includes(GameEvent.climax) &&
- intensity >= 100 &&
- (!events.includes(GameEvent.edge) || edged)
- ) {
- return GameEvent.climax;
- }
-
- if (events.includes(GameEvent.edge) && intensity >= 90 && !edged) {
- return GameEvent.edge;
- }
-
- if (events.includes(GameEvent.randomPace) && roll(10)) {
- return GameEvent.randomPace;
- }
-
- if (events.includes(GameEvent.cleanUp) && intensity >= 75 && roll(25)) {
- return GameEvent.cleanUp;
- }
-
- if (events.includes(GameEvent.randomGrip) && roll(50)) {
- return GameEvent.randomGrip;
- }
-
- if (
- events.includes(GameEvent.doublePace) &&
- intensity >= 20 &&
- roll(50 - (intensity - 20) * 0.25)
- ) {
- return GameEvent.doublePace;
- }
-
- if (
- events.includes(GameEvent.halfPace) &&
- intensity >= 10 &&
- intensity <= 50 &&
- roll(50)
- ) {
- return GameEvent.halfPace;
- }
-
- if (events.includes(GameEvent.pause) && intensity >= 15 && roll(50)) {
- return GameEvent.pause;
- }
-
- if (events.includes(GameEvent.risingPace) && intensity >= 30 && roll(30)) {
- return GameEvent.risingPace;
- }
-
- return null;
-};
-
-export const handleEvent = async (event: GameEvent, data: EventDataRef) => {
- await {
- climax: climaxEvent,
- edge: edgeEvent,
- pause: pauseEvent,
- halfPace: halfPaceEvent,
- risingPace: risingPaceEvent,
- doublePace: doublePaceEvent,
- randomPace: randomPaceEvent,
- randomGrip: randomGripEvent,
- cleanUp: cleanUpEvent,
- }[event](data);
-};
-
-export const silenceEventData = (data: EventDataRef): EventDataRef => {
- return {
- get current() {
- return {
- ...data.current,
- game: {
- ...data.current.game,
- sendMessage: () => {},
- },
- };
- },
- };
-};
-
-export const GameEvents = () => {
- const [phase] = useGameValue('phase');
- const [, setPaws] = useGameValue('paws');
- const [events] = useSetting('events');
- const sendMessage = useSendMessage();
-
- const data = useAutoRef({
- game: {
- ...createStateSetters(...useGame()),
- sendMessage: sendMessage,
- },
- settings: createStateSetters(...useSettings()),
- });
-
- useEffect(() => {
- if (phase === GamePhase.active && events.includes(GameEvent.randomGrip)) {
- randomGripEvent(silenceEventData(data));
- }
- }, [data, events, phase, setPaws]);
-
- useLooping(
- async () => {
- const event = rollEventDice(data);
- if (event) {
- await handleEvent(event, data);
- }
- },
- 1000,
- phase === GamePhase.active
- );
-
- return null;
-};
diff --git a/src/game/components/GameHypno.tsx b/src/game/components/GameHypno.tsx
index 238e1080..423e5f3c 100644
--- a/src/game/components/GameHypno.tsx
+++ b/src/game/components/GameHypno.tsx
@@ -1,10 +1,11 @@
import styled from 'styled-components';
import { useSetting, useTranslate } from '../../settings';
import { GameHypnoType, HypnoPhrases } from '../../types';
-import { useLooping } from '../../utils';
-import { GamePhase, useGameValue } from '../GameProvider';
-import { useCallback, useMemo } from 'react';
+import { useMemo } from 'react';
import { motion } from 'framer-motion';
+import { useGameFrame } from '../hooks';
+import Intensity from '../plugins/intensity';
+import Hypno from '../plugins/hypno';
const StyledGameHypno = motion.create(styled.div`
pointer-events: none;
@@ -15,33 +16,24 @@ const StyledGameHypno = motion.create(styled.div`
export const GameHypno = () => {
const [hypno] = useSetting('hypno');
- const [current, setCurrent] = useGameValue('currentHypno');
- const [phase] = useGameValue('phase');
- const [intensity] = useGameValue('intensity');
+ const { currentPhrase = 0 } = useGameFrame(Hypno.paths) ?? {};
+ const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {};
const translate = useTranslate();
const phrase = useMemo(() => {
+ if (hypno === GameHypnoType.off) return '';
const phrases = HypnoPhrases[hypno];
if (phrases.length <= 0) return '';
- return translate(phrases[current % phrases.length]);
- }, [current, hypno, translate]);
+ return translate(phrases[currentPhrase % phrases.length]);
+ }, [currentPhrase, hypno, translate]);
- const onTick = useCallback(() => {
- setCurrent(Math.floor(Math.random() * HypnoPhrases[hypno].length));
- }, [hypno, setCurrent]);
+ const delay = useMemo(() => 3000 - intensity * 100 * 29, [intensity]);
- const delay = useMemo(() => 3000 - intensity * 29, [intensity]);
-
- const enabled = useMemo(
- () => phase === GamePhase.active && hypno !== GameHypnoType.off,
- [phase, hypno]
- );
-
- useLooping(onTick, delay, enabled);
+ if (hypno === GameHypnoType.off || !phrase) return null;
return (
{
- const [images] = useImages();
- const [currentImage, setCurrentImage] = useGameValue('currentImage');
- const [seenImages, setSeenImages] = useGameValue('seenImages');
- const [nextImages, setNextImages] = useGameValue('nextImages');
- const [intensity] = useGameValue('intensity');
+ const { currentImage, nextImages = [] } = useGameFrame(Image.paths);
+ const { intensity } = useGameFrame(Intensity.paths);
const [videoSound] = useSetting('videoSound');
const [highRes] = useSetting('highRes');
- const [imageDuration] = useSetting('imageDuration');
- const [intenseImages] = useSetting('intenseImages');
useImagePreloader(nextImages, highRes ? ImageSize.full : ImageSize.preview);
- const imagesTracker = useAutoRef({
- images,
- currentImage,
- setCurrentImage,
- seenImages,
- setSeenImages,
- nextImages,
- setNextImages,
- });
-
- const switchImage = useCallback(() => {
- const {
- images,
- currentImage,
- setCurrentImage,
- seenImages,
- setSeenImages,
- nextImages,
- setNextImages,
- } = imagesTracker.current;
-
- let next = nextImages;
- if (next.length <= 0) {
- next = images.sort(() => Math.random() - 0.5).slice(0, 3);
- }
- const seen = [...seenImages, ...(currentImage ? [currentImage] : [])];
- if (seen.length > images.length / 2) {
- seen.shift();
- }
- const unseen = images.filter(i => !seen.includes(i));
- setCurrentImage(next.shift());
- setSeenImages(seen);
- setNextImages([...next, unseen[Math.floor(Math.random() * unseen.length)]]);
- }, [imagesTracker]);
-
- const switchDuration = useMemo(() => {
- if (intenseImages) {
- const scaleFactor = Math.max((100 - intensity) / 100, 0.1);
- return Math.max(imageDuration * scaleFactor * 1000, 1000);
- }
- return imageDuration * 1000;
- }, [imageDuration, intenseImages, intensity]);
-
- useEffect(() => switchImage(), [switchImage]);
-
- useLooping(switchImage, switchDuration);
+ const switchDuration = Math.max((100 - intensity * 100) * 80, 2000);
return (
@@ -119,7 +70,6 @@ export const GameImages = () => {
@@ -128,9 +78,11 @@ export const GameImages = () => {
{
- const [pace] = useGameValue('pace');
- const [intensity] = useGameValue('intensity');
+const PaceDisplay = () => {
+ const { pace = 0 } = useGameFrame(Pace.paths) ?? {};
const [maxPace] = useSetting('maxPace');
const paceSection = useMemo(() => maxPace / 3, [maxPace]);
- const [paws] = useGameValue('paws');
+ return (
+
+
+
+
+ paceSection && pace <= paceSection * 2}>
+
+
+ paceSection * 2}>
+
+
+
+ {pace} b/s
+
+
+ );
+};
+
+const GripDisplay = () => {
+ const paws = useGameFrame(pawsPath) ?? Paws.both;
+
+ return (
+
+
+
+
+
+
+
+
+ {PawLabels[paws]}
+
+
+ );
+};
+
+const IntensityDisplay = () => {
+ const { intensity = 0 } = useGameFrame(Intensity.paths) ?? {};
+ const intensityPct = Math.round(intensity * 100);
+
+ return (
+
+
+
+
+ );
+};
+
+export const GameInstructions = () => {
const [events] = useSetting('events');
const useRandomGrip = useMemo(
- () => events.includes(GameEvent.randomGrip),
+ () => events.includes(DiceEvent.randomGrip),
[events]
);
return (
-
-
-
-
- paceSection && pace <= paceSection * 2}
- >
-
-
- paceSection * 2}>
-
-
-
- {pace} b/s
-
-
+
{useRandomGrip && (
<>
-
-
-
-
-
-
-
-
- {PawLabels[paws]}
-
-
+
>
)}
-
-
-
-
+
);
};
diff --git a/src/game/components/GameIntensity.tsx b/src/game/components/GameIntensity.tsx
index 7df0bd08..056ab9a5 100644
--- a/src/game/components/GameIntensity.tsx
+++ b/src/game/components/GameIntensity.tsx
@@ -1,19 +1,33 @@
-import { useSetting } from '../../settings';
-import { useLooping } from '../../utils';
-import { GamePhase, useGameValue } from '../GameProvider';
+import { useGameFrame } from '../hooks';
+import Intensity from '../plugins/intensity';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faFire } from '@fortawesome/free-solid-svg-icons';
+import { ProgressBar } from '../../common';
+import styled from 'styled-components';
+
+const StyledIntensityMeter = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ margin: 8px;
+`;
export const GameIntensity = () => {
- const [, setIntensity] = useGameValue('intensity');
- const [phase] = useGameValue('phase');
- const [duration] = useSetting('gameDuration');
+ const { intensity } = useGameFrame(Intensity.paths);
- useLooping(
- () => {
- setIntensity(prev => Math.min(prev + 1, 100));
- },
- duration * 10,
- phase === GamePhase.active
+ return (
+
+
+
+
);
-
- return null;
};
diff --git a/src/game/components/GameMessages.tsx b/src/game/components/GameMessages.tsx
index 4449217e..0129dfab 100644
--- a/src/game/components/GameMessages.tsx
+++ b/src/game/components/GameMessages.tsx
@@ -1,10 +1,16 @@
-import { useCallback, useEffect, useState } from 'react';
-import styled from 'styled-components';
-import { GameMessage, GameMessagePrompt, useGameValue } from '../GameProvider';
-import { defaultTransition, playTone } from '../../utils';
+import { useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
+import styled from 'styled-components';
import { useTranslate } from '../../settings';
+import { defaultTransition, playTone } from '../../utils';
+import { GameMessage } from '../plugins/messages';
+import Messages from '../plugins/messages';
+import { useGameFrame } from '../hooks/UseGameFrame';
+
+import _ from 'lodash';
+import { useDispatchEvent } from '../hooks/UseDispatchEvent';
+
const StyledGameMessages = styled.div`
display: flex;
flex-direction: column;
@@ -54,83 +60,37 @@ const StyledGameMessageButton = motion.create(styled.button`
`);
export const GameMessages = () => {
- const [, setTimers] = useState>({});
- const [currentMessages, setCurrentMessages] = useState([]);
- const [previousMessages, setPreviousMessages] = useState([]);
- const [messages, setMessages] = useGameValue('messages');
+ const { messages } = useGameFrame(Messages.paths);
+ const { dispatchEvent } = useDispatchEvent();
const translate = useTranslate();
- useEffect(() => {
- setPreviousMessages(currentMessages);
- setCurrentMessages(messages);
- }, [currentMessages, messages]);
+ const prevMessagesRef = useRef([]);
useEffect(() => {
- const addedMessages = currentMessages.filter(
- message => !previousMessages.includes(message)
- );
-
- const newTimers = addedMessages.reduce(
- (acc, message) => {
- if (message.duration) {
- acc[message.id] = window.setTimeout(
- () => setMessages(messages => messages.filter(m => m !== message)),
- message.duration
- );
- }
- return acc;
- },
- {} as Record
- );
-
- if (addedMessages.length > 0) {
+ const prevMessages = prevMessagesRef.current;
+
+ const changed = messages?.some(newMsg => {
+ const oldMsg = prevMessages.find(prev => prev.id === newMsg.id);
+ return !oldMsg || !_.isEqual(oldMsg, newMsg);
+ });
+
+ if (changed) {
playTone(200);
}
- const removedMessages = previousMessages.filter(
- message => !currentMessages.includes(message)
- );
- const removedTimers = removedMessages.map(message => message.id);
-
- setTimers(timers => ({
- ...Object.keys(timers).reduce((acc, key) => {
- if (removedTimers.includes(key)) {
- window.clearTimeout(timers[key]);
- return acc;
- }
- return { ...acc, [key]: timers[key] };
- }, {}),
- ...newTimers,
- }));
- }, [currentMessages, previousMessages, setMessages]);
-
- const onMessageClick = useCallback(
- async (message: GameMessage, prompt: GameMessagePrompt) => {
- await prompt.onClick();
- setMessages(messages => messages.filter(m => m !== message));
- },
- [setMessages]
- );
+ prevMessagesRef.current = messages ?? [];
+ }, [messages]);
return (
- {currentMessages.map(message => (
+ {messages?.map(message => (
{translate(message.title)}
@@ -159,7 +119,7 @@ export const GameMessages = () => {
...defaultTransition,
ease: 'circInOut',
}}
- onClick={() => onMessageClick(message, prompt)}
+ onClick={() => dispatchEvent(prompt.event)}
>
{translate(prompt.title)}
diff --git a/src/game/components/GameMeter.tsx b/src/game/components/GameMeter.tsx
index ad12661e..635bf501 100644
--- a/src/game/components/GameMeter.tsx
+++ b/src/game/components/GameMeter.tsx
@@ -1,8 +1,11 @@
import styled from 'styled-components';
-import { GamePhase, Stroke, useGameValue } from '../GameProvider';
-import { useCallback, useMemo } from 'react';
+import { useMemo } from 'react';
import { motion } from 'framer-motion';
-import { defaultTransition, useLooping } from '../../utils';
+import { defaultTransition } from '../../utils';
+import { useGameFrame } from '../hooks';
+import Phase, { GamePhase } from '../plugins/phase';
+import Stroke, { StrokeDirection } from '../plugins/stroke';
+import Pace from '../plugins/pace';
const StyledGameMeter = styled.div`
pointer-events: none;
@@ -22,70 +25,33 @@ enum MeterColor {
}
export const GameMeter = () => {
- const [stroke, setStroke] = useGameValue('stroke');
- const [phase] = useGameValue('phase');
- const [pace] = useGameValue('pace');
+ const { stroke } = useGameFrame(Stroke.paths) ?? {};
+ const { current: phase } = useGameFrame(Phase.paths) ?? {};
+ const { pace } = useGameFrame(Pace.paths) ?? {};
const switchDuration = useMemo(() => {
- if (pace === 0) return 0;
+ if (!pace || pace === 0) return 0;
return (1 / pace) * 1000;
}, [pace]);
- const updateStroke = useCallback(() => {
- setStroke(stroke => {
- switch (stroke) {
- case Stroke.up:
- return Stroke.down;
- case Stroke.down:
- return Stroke.up;
- }
- });
- }, [setStroke]);
-
- useLooping(
- updateStroke,
- switchDuration,
- [GamePhase.active, GamePhase.finale].includes(phase) && pace > 0
- );
-
const size = useMemo(() => {
- switch (phase) {
- case GamePhase.active:
- case GamePhase.finale:
- return (() => {
- switch (stroke) {
- case Stroke.up:
- return 1;
- case Stroke.down:
- return 0.6;
- }
- })();
+ if (phase === GamePhase.active || phase === GamePhase.finale) {
+ return stroke === StrokeDirection.up ? 1 : 0.6;
}
return 0;
}, [phase, stroke]);
const duration = useMemo(() => {
- switch (phase) {
- case GamePhase.active:
- case GamePhase.finale:
- if (pace >= 5) {
- return 100;
- }
- if (pace >= 3) {
- return 250;
- }
- return 550;
+ if (phase === GamePhase.active || phase === GamePhase.finale) {
+ if (pace && pace >= 5) return 100;
+ if (pace && pace >= 3) return 250;
+ return 550;
}
return 0;
}, [phase, pace]);
const color = useMemo(() => {
- switch (stroke) {
- case Stroke.up:
- return MeterColor.light;
- case Stroke.down:
- return MeterColor.dark;
- }
+ return stroke === StrokeDirection.up ? MeterColor.light : MeterColor.dark;
}, [stroke]);
return (
diff --git a/src/game/components/GamePace.tsx b/src/game/components/GamePace.tsx
deleted file mode 100644
index 56dec88a..00000000
--- a/src/game/components/GamePace.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useEffect } from 'react';
-import { useGameValue } from '../GameProvider';
-import { useSetting } from '../../settings';
-
-export const GamePace = () => {
- const [minPace] = useSetting('minPace');
- const [, setPace] = useGameValue('pace');
-
- useEffect(() => {
- setPace(minPace);
- }, [minPace, setPace]);
-
- return null;
-};
diff --git a/src/game/components/GamePauseMenu.tsx b/src/game/components/GamePauseMenu.tsx
new file mode 100644
index 00000000..5f4d869b
--- /dev/null
+++ b/src/game/components/GamePauseMenu.tsx
@@ -0,0 +1,126 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+import { WaButton, WaDialog, WaIcon } from '@awesome.me/webawesome/dist/react';
+import { nestedDialogProps, useFullscreen } from '../../utils';
+import { useGameFrame } from '../hooks';
+import { useDispatchEvent } from '../hooks/UseDispatchEvent';
+import Pause from '../plugins/pause';
+
+const StyledTrigger = styled.div`
+ display: flex;
+ height: fit-content;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0 var(--border-radius) 0 0;
+ background: var(--overlay-background);
+ color: var(--overlay-color);
+ padding: var(--wa-space-2xs);
+`;
+
+const StyledDialog = styled(WaDialog)`
+ &::part(dialog) {
+ background-color: transparent;
+ box-shadow: none;
+ }
+
+ &::part(header) {
+ justify-content: center;
+ }
+
+ &::part(title) {
+ text-align: center;
+ }
+
+ &::part(close-button) {
+ display: none;
+ }
+
+ &::part(body) {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--wa-space-m);
+ max-width: 300px;
+ padding: var(--wa-space-l) 0;
+ margin: 0 auto;
+ }
+`;
+
+const StyledMenuButton = styled(WaButton)`
+ &::part(base) {
+ padding: var(--wa-space-l) var(--wa-space-2xl);
+ background: var(--overlay-background);
+ border-radius: var(--wa-form-control-border-radius);
+ font-size: 1.25rem;
+ }
+
+ &::part(base):hover {
+ background: var(--wa-color-brand);
+ }
+`;
+
+export const GamePauseMenu = () => {
+ const { paused, countdown } = useGameFrame(Pause.paths) ?? {};
+ const [fullscreen, setFullscreen] = useFullscreen();
+ const { inject } = useDispatchEvent();
+ const navigate = useNavigate();
+ const dialogRef = useRef(null);
+
+ const visible = !!paused && countdown == null;
+
+ useEffect(() => {
+ if (dialogRef.current) dialogRef.current.open = visible;
+ }, [visible]);
+
+ const onResume = useCallback(() => {
+ inject(Pause.setPaused(false));
+ }, [inject]);
+
+ const onEndGame = useCallback(() => {
+ navigate('/end');
+ }, [navigate]);
+
+ const onSettings = useCallback(() => {
+ navigate('/');
+ }, [navigate]);
+
+ const onPause = useCallback(() => {
+ inject(Pause.setPaused(true));
+ }, [inject]);
+
+ return (
+ <>
+
+
+
+
+
+ {
+ if (countdown != null) return;
+ inject(Pause.setPaused(false));
+ })}
+ >
+
+
+ Resume
+
+ setFullscreen(fs => !fs)}>
+
+ {fullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
+
+
+
+ Settings
+
+
+
+ End Game
+
+
+ >
+ );
+};
diff --git a/src/game/components/GameResume.tsx b/src/game/components/GameResume.tsx
new file mode 100644
index 00000000..bd34bd53
--- /dev/null
+++ b/src/game/components/GameResume.tsx
@@ -0,0 +1,50 @@
+import styled from 'styled-components';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useGameFrame } from '../hooks';
+import Pause from '../plugins/pause';
+
+const StyledOverlay = styled(motion.div)`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 10;
+`;
+
+const StyledNumber = styled(motion.div)`
+ font-size: clamp(3rem, 15vw, 8rem);
+ font-weight: bold;
+ color: var(--overlay-color);
+`;
+
+const display = (countdown: number) =>
+ countdown === 3 ? 'Ready?' : `${countdown}`;
+
+export const GameResume = () => {
+ const { countdown } = useGameFrame(Pause.paths) ?? {};
+
+ return (
+
+ {countdown != null && (
+
+
+ {display(countdown)}
+
+
+ )}
+
+ );
+};
diff --git a/src/game/components/GameSettings.tsx b/src/game/components/GameSettings.tsx
deleted file mode 100644
index 0352ab1c..00000000
--- a/src/game/components/GameSettings.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import styled from 'styled-components';
-import { memo, useCallback, useState } from 'react';
-import {
- BoardSettings,
- ClimaxSettings,
- DurationSettings,
- EventSettings,
- HypnoSettings,
- ImageSettings,
- PaceSettings,
- PlayerSettings,
- VibratorSettings,
-} from '../../settings';
-import { GamePhase, useGameValue, useSendMessage } from '../GameProvider';
-import { useFullscreen, useLooping } from '../../utils';
-import {
- WaButton,
- WaDialog,
- WaDivider,
- WaIcon,
-} from '@awesome.me/webawesome/dist/react';
-
-const StyledGameSettings = styled.div`
- display: flex;
- height: fit-content;
-
- align-items: center;
- justify-content: center;
-
- border-radius: 0 var(--border-radius) 0 0;
- background: var(--overlay-background);
- color: var(--overlay-color);
-
- padding: var(--wa-space-2xs);
-`;
-
-const StyledGameSettingsDialog = styled.div`
- overflow: auto;
- max-width: 920px;
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr));
-`;
-
-const GameSettingsDialogContent = memo(() => (
-
-
-
-
-
-
-
-
-
-
-
-));
-
-export const GameSettings = () => {
- const [open, setOpen] = useState(false);
- const [phase, setPhase] = useGameValue('phase');
- const [timer, setTimer] = useState(undefined);
- const [fullscreen, setFullscreen] = useFullscreen();
- const sendMessage = useSendMessage();
- const messageId = 'game-settings';
-
- const onOpen = useCallback(
- (open: boolean) => {
- if (open) {
- setTimer(undefined);
- setPhase(phase => {
- if (phase === GamePhase.active) {
- return GamePhase.pause;
- }
- return phase;
- });
- } else {
- setTimer(3000);
- }
- setOpen(open);
- },
- [setPhase]
- );
-
- useLooping(
- () => {
- if (timer === undefined) return;
- if (timer > 0) {
- sendMessage({
- id: messageId,
- title: 'Get ready to continue.',
- description: `${timer * 0.001}...`,
- });
- setTimer(timer - 1000);
- } else if (timer === 0) {
- sendMessage({
- id: messageId,
- title: 'Continue.',
- description: undefined,
- duration: 1500,
- });
- setPhase(GamePhase.active);
- setTimer(undefined);
- }
- },
- 1000,
- !open && phase === GamePhase.pause && timer !== undefined
- );
-
- return (
-
- onOpen(true)}>
-
-
-
- setFullscreen(fullscreen => !fullscreen)}
- >
-
-
- onOpen(false)}
- label={'Game Settings'}
- style={{
- '--width': '920px',
- }}
- >
- {open && }
-
-
- );
-};
diff --git a/src/game/components/GameSound.tsx b/src/game/components/GameSound.tsx
index b43d958d..195dfdb4 100644
--- a/src/game/components/GameSound.tsx
+++ b/src/game/components/GameSound.tsx
@@ -1,20 +1,22 @@
import { useEffect, useState } from 'react';
-import { GamePhase, Stroke, useGameValue } from '../GameProvider';
import { playTone } from '../../utils/sound';
import { wait } from '../../utils';
+import { useGameFrame } from '../hooks';
+import Phase, { GamePhase } from '../plugins/phase';
+import Stroke, { StrokeDirection } from '../plugins/stroke';
export const GameSound = () => {
- const [stroke] = useGameValue('stroke');
- const [phase] = useGameValue('phase');
+ const { stroke } = useGameFrame(Stroke.paths) ?? {};
+ const { current: phase } = useGameFrame(Phase.paths) ?? {};
const [currentPhase, setCurrentPhase] = useState(phase);
useEffect(() => {
switch (stroke) {
- case Stroke.up:
+ case StrokeDirection.up:
playTone(425);
break;
- case Stroke.down:
+ case StrokeDirection.down:
playTone(625);
break;
}
diff --git a/src/game/components/GameVibrator.tsx b/src/game/components/GameVibrator.tsx
index 7ed42337..63c7db4e 100644
--- a/src/game/components/GameVibrator.tsx
+++ b/src/game/components/GameVibrator.tsx
@@ -1,19 +1,25 @@
import { useEffect, useState } from 'react';
-import { GamePhase, Stroke, useGameValue } from '../GameProvider';
import { useAutoRef, useVibratorValue, VibrationMode, wait } from '../../utils';
import { useSetting } from '../../settings';
+import { useGameFrame } from '../hooks';
+import { GamePhase } from '../plugins/phase';
+import Phase from '../plugins/phase';
+import { StrokeDirection } from '../plugins/stroke';
+import Stroke from '../plugins/stroke';
+import Pace from '../plugins/pace';
+import Intensity from '../plugins/intensity';
export const GameVibrator = () => {
- const [stroke] = useGameValue('stroke');
- const [intensity] = useGameValue('intensity');
- const [pace] = useGameValue('pace');
- const [phase] = useGameValue('phase');
+ const { stroke } = useGameFrame(Stroke.paths) ?? {};
+ const { intensity } = useGameFrame(Intensity.paths) ?? {};
+ const { pace } = useGameFrame(Pace.paths) ?? {};
+ const { current: phase } = useGameFrame(Phase.paths) ?? {};
const [mode] = useSetting('vibrations');
const [devices] = useVibratorValue('devices');
const data = useAutoRef({
- intensity,
- pace,
+ intensity: (intensity ?? 0) * 100,
+ pace: pace ?? 1,
devices,
mode,
});
@@ -23,7 +29,7 @@ export const GameVibrator = () => {
useEffect(() => {
const { intensity, pace, devices, mode } = data.current;
switch (stroke) {
- case Stroke.up:
+ case StrokeDirection.up:
switch (mode) {
case VibrationMode.constant: {
const strength = intensity / 100;
@@ -38,7 +44,7 @@ export const GameVibrator = () => {
}
}
break;
- case Stroke.down:
+ case StrokeDirection.down:
break;
}
}, [data, stroke]);
@@ -46,7 +52,7 @@ export const GameVibrator = () => {
useEffect(() => {
const { devices, mode } = data.current;
if (currentPhase == phase) return;
- if ([GamePhase.break, GamePhase.pause].includes(phase)) {
+ if (phase === GamePhase.break) {
devices.forEach(device => device.setVibration(0));
}
if (phase === GamePhase.climax) {
diff --git a/src/game/components/GameWarmup.tsx b/src/game/components/GameWarmup.tsx
deleted file mode 100644
index 65f7bc09..00000000
--- a/src/game/components/GameWarmup.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useSetting } from '../../settings';
-import { GamePhase, useGameValue, useSendMessage } from '../GameProvider';
-
-export const GameWarmup = () => {
- const [warmup] = useSetting('warmupDuration');
- const [phase, setPhase] = useGameValue('phase');
- const [, setTimer] = useState(null);
- const sendMessage = useSendMessage();
-
- const onStart = useCallback(() => {
- setPhase(GamePhase.active);
- setTimer(timer => {
- if (timer) window.clearTimeout(timer);
- return null;
- });
- sendMessage({
- id: GamePhase.warmup,
- title: 'Now follow what I say, $player!',
- duration: 5000,
- prompts: undefined,
- });
- }, [sendMessage, setPhase]);
-
- useEffect(() => {
- if (phase !== GamePhase.warmup) return;
- if (warmup === 0) {
- setPhase(GamePhase.active);
- return;
- }
- setTimer(window.setTimeout(onStart, warmup * 1000));
-
- sendMessage({
- id: GamePhase.warmup,
- title: 'Get yourself ready!',
- prompts: [
- {
- title: `I'm ready, $master`,
- onClick: onStart,
- },
- ],
- });
-
- return () => {
- setTimer(timer => {
- if (timer) window.clearTimeout(timer);
- return null;
- });
- };
- }, [onStart, phase, sendMessage, setPhase, warmup]);
-
- return null;
-};
diff --git a/src/game/components/events/clean-up.ts b/src/game/components/events/clean-up.ts
deleted file mode 100644
index d44687e8..00000000
--- a/src/game/components/events/clean-up.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { GameEvent, CleanUpDescriptions } from '../../../types';
-import { GamePhase } from '../../GameProvider';
-import { EventDataRef } from '../GameEvents';
-
-export const cleanUpEvent = async (data: EventDataRef) => {
- const {
- game: { setPhase, sendMessage },
- settings: { body },
- } = data.current;
-
- setPhase(GamePhase.pause);
- sendMessage({
- id: GameEvent.cleanUp,
- title: `Lick up any ${CleanUpDescriptions[body]}`,
- duration: undefined,
- prompts: [
- {
- title: `I'm done, $master`,
- onClick: () => {
- sendMessage({
- id: GameEvent.cleanUp,
- title: 'Good $player',
- duration: 5000,
- prompts: undefined,
- });
- setPhase(GamePhase.active);
- },
- },
- ],
- });
-};
diff --git a/src/game/components/events/climax.ts b/src/game/components/events/climax.ts
deleted file mode 100644
index 8daaa668..00000000
--- a/src/game/components/events/climax.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { GameEvent } from '../../../types';
-import { wait } from '../../../utils';
-import { GamePhase } from '../../GameProvider';
-import { EventDataRef } from '../GameEvents';
-
-export const climaxEvent = (data: EventDataRef) => {
- const {
- game: { setPhase, sendMessage, setPace, setIntensity },
- settings: { minPace, climaxChance, ruinChance },
- } = data.current;
-
- setPhase(GamePhase.finale); // this disables events
- sendMessage({
- id: GameEvent.climax,
- title: 'Are you edging?',
- prompts: [
- {
- title: "I'm edging, $master",
- onClick: async () => {
- sendMessage({
- id: GameEvent.climax,
- title: 'Stay on the edge, $player',
- prompts: undefined,
- });
- setPace(minPace);
- await wait(3000);
- sendMessage({
- id: GameEvent.climax,
- description: '3...',
- });
- await wait(5000);
- sendMessage({
- id: GameEvent.climax,
- description: '2...',
- });
- await wait(5000);
- sendMessage({
- id: GameEvent.climax,
- description: '1...',
- });
- await wait(5000);
-
- if (Math.random() * 100 <= climaxChance) {
- if (Math.random() * 100 <= ruinChance) {
- setPhase(GamePhase.pause);
- sendMessage({
- id: GameEvent.climax,
- title: '$HANDS OFF! Ruin your orgasm!',
- description: undefined,
- });
- await wait(3000);
- sendMessage({
- id: GameEvent.climax,
- title: 'Clench in desperation',
- });
- } else {
- setPhase(GamePhase.climax);
- sendMessage({
- id: GameEvent.climax,
- title: 'Cum!',
- description: undefined,
- });
- }
- for (let i = 0; i < 10; i++) {
- setIntensity(intensity => intensity - 10);
- await wait(1000);
- }
- sendMessage({
- id: GameEvent.climax,
- title: 'Good job, $player',
- prompts: [
- {
- title: 'Leave',
- onClick: () => {
- window.location.href = '/';
- },
- },
- ],
- });
- } else {
- setPhase(GamePhase.pause);
- sendMessage({
- id: GameEvent.climax,
- title: '$HANDS OFF! Do not cum!',
- description: undefined,
- });
- for (let i = 0; i < 5; i++) {
- setIntensity(intensity => intensity - 20);
- await wait(1000);
- }
- sendMessage({
- id: GameEvent.climax,
- title: 'Good $player. Let yourself cool off',
- });
- await wait(5000);
- sendMessage({
- id: GameEvent.climax,
- title: 'Leave now.',
- prompts: [
- {
- title: 'Leave',
- onClick: () => {
- window.location.href = '/';
- },
- },
- ],
- });
- }
- },
- },
- {
- title: "I can't",
- onClick: async () => {
- sendMessage({
- id: GameEvent.climax,
- title: "You're pathetic. Stop for a moment",
- });
- setPhase(GamePhase.pause);
- setIntensity(0); // TODO: this essentially restarts the game. is this a good idea?
- await wait(20000);
- sendMessage({
- id: GameEvent.climax,
- title: 'Start to $stroke again',
- duration: 5000,
- });
- setPace(minPace);
- setPhase(GamePhase.active);
- await wait(15000);
- },
- },
- ],
- });
-};
diff --git a/src/game/components/events/double-pace.ts b/src/game/components/events/double-pace.ts
deleted file mode 100644
index 3d71ea6e..00000000
--- a/src/game/components/events/double-pace.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { GameEvent } from '../../../types';
-import { round, wait } from '../../../utils';
-import { EventDataRef, silenceEventData } from '../GameEvents';
-import { randomPaceEvent } from './random-pace';
-
-export const doublePaceEvent = async (data: EventDataRef) => {
- const {
- game: { pace, setPace, sendMessage },
- settings: { maxPace },
- } = data.current;
- const newPace = Math.min(round(pace * 2), maxPace);
- setPace(newPace);
- sendMessage({
- id: GameEvent.doublePace,
- title: 'Double pace!',
- });
- const duration = 9000;
- const durationPortion = duration / 3;
- sendMessage({
- id: GameEvent.doublePace,
- description: '3...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.doublePace,
- description: '2...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.doublePace,
- description: '1...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.doublePace,
- title: 'Done! Back to normal pace',
- description: undefined,
- duration: 5000,
- });
-
- randomPaceEvent(silenceEventData(data));
-};
diff --git a/src/game/components/events/edge.ts b/src/game/components/events/edge.ts
deleted file mode 100644
index d91a97f4..00000000
--- a/src/game/components/events/edge.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { GameEvent } from '../../../types';
-import { wait } from '../../../utils';
-import { EventDataRef } from '../GameEvents';
-
-export const edgeEvent = async (data: EventDataRef) => {
- const {
- game: { setEdged, setPace, sendMessage },
- settings: { minPace },
- } = data.current;
-
- setEdged(true);
- setPace(minPace);
- sendMessage({
- id: GameEvent.edge,
- title: `You should getting close to the edge. Don't cum yet.`,
- duration: 10000,
- });
- await wait(10000);
-};
diff --git a/src/game/components/events/half-pace.ts b/src/game/components/events/half-pace.ts
deleted file mode 100644
index 8c095fdd..00000000
--- a/src/game/components/events/half-pace.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { GameEvent } from '../../../types';
-import { round, wait } from '../../../utils';
-import { EventDataRef, silenceEventData } from '../GameEvents';
-import { randomPaceEvent } from './random-pace';
-
-export const halfPaceEvent = async (data: EventDataRef) => {
- const {
- game: { pace, setPace, sendMessage },
- settings: { minPace },
- } = data.current;
-
- sendMessage({
- id: GameEvent.halfPace,
- title: 'Half pace!',
- });
- const newPace = Math.max(round(pace / 2), minPace);
- setPace(newPace);
- const duration = Math.ceil(Math.random() * 20000) + 12000;
- const durationPortion = duration / 3;
- sendMessage({
- id: GameEvent.halfPace,
- description: '3...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.halfPace,
- description: '2...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.halfPace,
- description: '1...',
- });
- await wait(durationPortion);
- sendMessage({
- id: GameEvent.halfPace,
- title: 'Done! Back to normal pace',
- description: undefined,
- duration: 5000,
- });
-
- randomPaceEvent(silenceEventData(data));
-};
diff --git a/src/game/components/events/index.ts b/src/game/components/events/index.ts
deleted file mode 100644
index 3fa73b31..00000000
--- a/src/game/components/events/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from './clean-up';
-export * from './climax';
-export * from './double-pace';
-export * from './edge';
-export * from './half-pace';
-export * from './pause';
-export * from './random-grip';
-export * from './random-pace';
-export * from './rising-pace';
diff --git a/src/game/components/events/pause.ts b/src/game/components/events/pause.ts
deleted file mode 100644
index 8229edc1..00000000
--- a/src/game/components/events/pause.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { GameEvent } from '../../../types';
-import { wait } from '../../../utils';
-import { GamePhase } from '../../GameProvider';
-import { EventDataRef } from '../GameEvents';
-
-export const pauseEvent = async (data: EventDataRef) => {
- const {
- game: { intensity, setPhase, sendMessage },
- } = data.current;
-
- sendMessage({
- id: GameEvent.pause,
- title: 'Stop stroking!',
- });
- setPhase(GamePhase.pause);
- const duration = Math.ceil(-100 * intensity + 12000);
- await wait(duration);
- sendMessage({
- id: GameEvent.pause,
- title: 'Start stroking again!',
- duration: 5000,
- });
- setPhase(GamePhase.active);
-};
diff --git a/src/game/components/events/random-grip.ts b/src/game/components/events/random-grip.ts
deleted file mode 100644
index 14c65046..00000000
--- a/src/game/components/events/random-grip.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { GameEvent } from '../../../types';
-import { wait } from '../../../utils';
-import { Paws, PawLabels } from '../../GameProvider';
-import { EventDataRef } from '../GameEvents';
-
-export const randomGripEvent = async (data: EventDataRef) => {
- const {
- game: { paws, setPaws, sendMessage },
- } = data.current;
-
- let newPaws: Paws;
- const seed = Math.random();
- if (seed < 0.33) newPaws = paws === Paws.both ? Paws.left : Paws.both;
- if (seed < 0.66) newPaws = paws === Paws.left ? Paws.right : Paws.left;
- newPaws = paws === Paws.right ? Paws.both : Paws.right;
- setPaws(newPaws);
- sendMessage({
- id: GameEvent.randomGrip,
- title: `Grip changed to ${PawLabels[newPaws]}!`,
- duration: 5000,
- });
- await wait(10000);
-};
diff --git a/src/game/components/events/random-pace.ts b/src/game/components/events/random-pace.ts
deleted file mode 100644
index d1212cee..00000000
--- a/src/game/components/events/random-pace.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { GameEvent } from '../../../types';
-import { intensityToPaceRange, round, wait } from '../../../utils';
-import { EventDataRef } from '../GameEvents';
-
-export const randomPaceEvent = async (data: EventDataRef) => {
- const {
- game: { intensity, setPace, sendMessage },
- settings: { minPace, maxPace, steepness, timeshift },
- } = data.current;
-
- const { min, max } = intensityToPaceRange(intensity, steepness, timeshift, {
- min: minPace,
- max: maxPace,
- });
- const newPace = round(Math.random() * (max - min) + min);
- setPace(newPace);
- sendMessage({
- id: GameEvent.randomPace,
- title: `Pace changed to ${newPace}!`,
- duration: 5000,
- });
- await wait(9000);
-};
diff --git a/src/game/components/events/rising-pace.ts b/src/game/components/events/rising-pace.ts
deleted file mode 100644
index 9241865a..00000000
--- a/src/game/components/events/rising-pace.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { GameEvent } from '../../../types';
-import { intensityToPaceRange, wait, round } from '../../../utils';
-import { EventDataRef } from '../GameEvents';
-import { randomPaceEvent } from './random-pace';
-
-export const risingPaceEvent = async (data: EventDataRef) => {
- const {
- game: { intensity, setPace, sendMessage },
- settings: { minPace, maxPace, steepness, timeshift },
- } = data.current;
-
- sendMessage({
- id: GameEvent.risingPace,
- title: 'Rising pace strokes!',
- });
- const acceleration = Math.round(100 / Math.min(intensity, 35));
- const { max } = intensityToPaceRange(intensity, steepness, timeshift, {
- min: minPace,
- max: maxPace,
- });
- const portion = (max - minPace) / acceleration;
- let newPace = minPace;
- setPace(newPace);
- for (let i = 0; i < acceleration; i++) {
- await wait(10000);
- newPace = round(newPace + portion);
- setPace(newPace);
- sendMessage({
- id: GameEvent.risingPace,
- title: `Pace rising to ${newPace}!`,
- duration: 5000,
- });
- }
- await wait(10000);
- sendMessage({
- id: GameEvent.risingPace,
- title: 'Stay at this pace for a bit',
- duration: 5000,
- });
- await wait(15000);
-
- randomPaceEvent(data);
-};
diff --git a/src/game/components/index.ts b/src/game/components/index.ts
index 4cb0ba1e..0c28243c 100644
--- a/src/game/components/index.ts
+++ b/src/game/components/index.ts
@@ -1,14 +1,11 @@
-export * from './events';
export * from './GameEmergencyStop';
-export * from './GameEvents';
export * from './GameHypno';
export * from './GameImages';
export * from './GameInstructions';
export * from './GameIntensity';
export * from './GameMessages';
export * from './GameMeter';
-export * from './GamePace';
-export * from './GameSettings';
+export * from './GameResume';
+export * from './GamePauseMenu';
export * from './GameSound';
export * from './GameVibrator';
-export * from './GameWarmup';
diff --git a/src/game/hooks/UseDispatchEvent.tsx b/src/game/hooks/UseDispatchEvent.tsx
new file mode 100644
index 00000000..6a5ef4e5
--- /dev/null
+++ b/src/game/hooks/UseDispatchEvent.tsx
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import { useContextSelector } from 'use-context-selector';
+import { Events, GameEvent } from '../../engine/pipes/Events';
+import { Pipe } from '../../engine/State';
+import { GameEngineContext } from '../GameProvider';
+
+export function useDispatchEvent() {
+ const injectImpulse = useContextSelector(
+ GameEngineContext,
+ ctx => ctx?.injectImpulse
+ );
+
+ return useMemo(
+ () => ({
+ inject: (pipe: Pipe) => {
+ injectImpulse?.(pipe);
+ },
+ dispatchEvent: (event: GameEvent) => {
+ injectImpulse?.(Events.dispatch(event));
+ },
+ }),
+ [injectImpulse]
+ );
+}
diff --git a/src/game/hooks/UseGameEngine.tsx b/src/game/hooks/UseGameEngine.tsx
new file mode 100644
index 00000000..be226ac4
--- /dev/null
+++ b/src/game/hooks/UseGameEngine.tsx
@@ -0,0 +1,9 @@
+import { useContext } from 'use-context-selector';
+import { GameEngineContext } from '../GameProvider';
+
+export function useGameEngine() {
+ const ctx = useContext(GameEngineContext);
+ if (!ctx)
+ throw new Error('useGameEngine must be used inside GameEngineProvider');
+ return ctx;
+}
diff --git a/src/game/hooks/UseGameFrame.tsx b/src/game/hooks/UseGameFrame.tsx
new file mode 100644
index 00000000..e9f211ad
--- /dev/null
+++ b/src/game/hooks/UseGameFrame.tsx
@@ -0,0 +1,11 @@
+import { useContextSelector } from 'use-context-selector';
+import { lensFromPath, normalizePath, Path } from '../../engine/Lens';
+import { GameEngineContext } from '../GameProvider';
+
+export const useGameFrame = (path: Path): T => {
+ return useContextSelector(GameEngineContext, ctx => {
+ if (!ctx?.frame) return {} as T;
+ const segments = normalizePath(path);
+ return lensFromPath