diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 5da30edede..9aec7c865c 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -4,9 +4,11 @@ import './App.css'; import arrow from './assets/arrow.png'; import lynxLogo from './assets/lynx-logo.png'; import reactLynxLogo from './assets/react-logo.png'; +import { useFlappy } from './useFlappy.js'; export function App() { const [alterLogo, setAlterLogo] = useState(false); + const [logoY, jump] = useFlappy(); useEffect(() => { console.info('Hello, ReactLynx'); @@ -18,11 +20,15 @@ export function App() { }, []); return ( - + - + {alterLogo ? : } diff --git a/examples/react/src/lib/flappy.ts b/examples/react/src/lib/flappy.ts new file mode 100644 index 0000000000..9f5e304ed0 --- /dev/null +++ b/examples/react/src/lib/flappy.ts @@ -0,0 +1,76 @@ +/** + * Framework-agnostic flappy-bird physics engine. + * + * Manages gravity, jump impulse, and a 60fps game loop. + * Wire it up to any UI framework by calling `jump()` on tap + * and reading `getY()` in the loop callback. + */ + +export interface FlappyOptions { + /** Downward acceleration per frame (default 0.6) */ + gravity?: number; + /** Upward impulse per tap — negative value (default -12) */ + jumpForce?: number; + /** Impulse stacking factor for rapid taps (default 0.6) */ + stackFactor?: number; + /** Frame interval in ms (default 16 ≈ 60fps) */ + frameMs?: number; +} + +export type OnUpdate = (y: number) => void; + +export interface FlappyEngine { + /** Call on each tap to apply upward impulse. */ + jump(): void; + /** Current Y offset (0 = ground, negative = airborne). */ + getY(): number; + /** Stop the game loop and clean up. */ + destroy(): void; +} + +export function createFlappy( + onUpdate: OnUpdate, + options: FlappyOptions = {}, +): FlappyEngine { + const { + gravity = 0.6, + jumpForce = -12, + stackFactor = 0.6, + frameMs = 16, + } = options; + + let y = 0; + let velocity = 0; + let timer: ReturnType | null = null; + + function loop() { + velocity += gravity; + y += velocity; + if (y >= 0) { + y = 0; + velocity = 0; + timer = null; + onUpdate(y); + return; + } + onUpdate(y); + timer = setTimeout(loop, frameMs); + } + + function jump() { + // Stack impulse on rapid taps, clamped to one full jumpForce + velocity = Math.max(velocity + jumpForce * stackFactor, jumpForce); + if (!timer) { + loop(); + } + } + + function destroy() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + + return { jump, getY: () => y, destroy }; +} diff --git a/examples/react/src/useFlappy.ts b/examples/react/src/useFlappy.ts new file mode 100644 index 0000000000..14edcc3ed3 --- /dev/null +++ b/examples/react/src/useFlappy.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from '@lynx-js/react'; + +import { createFlappy } from './lib/flappy.js'; +import type { FlappyEngine, FlappyOptions } from './lib/flappy.js'; + +/** + * React hook for flappy-bird physics. + * + * Returns `[y, jump]` — a state value and a stable callback. + * The game loop runs automatically; cleanup happens on unmount. + * Options are read once on mount and not reactive to later changes. + * + * @example + * ```tsx + * function Bird() { + * const [y, jump] = useFlappy(); + * return ( + * + * Tap me! + * + * ); + * } + * ``` + */ +export function useFlappy( + options?: FlappyOptions, +): [number, () => void] { + const [y, setY] = useState(0); + const engineRef = useRef(null); + + engineRef.current ??= createFlappy((newY) => { + setY(newY); + }, options); + + useEffect(() => { + return () => { + engineRef.current?.destroy(); + }; + }, []); + + const jump = useCallback(() => { + 'background-only'; + engineRef.current?.jump(); + }, []); + + return [y, jump]; +} diff --git a/packages/rspeedy/create-rspeedy/template-react-js/src/App.jsx b/packages/rspeedy/create-rspeedy/template-react-js/src/App.jsx index 00ba284ab7..e5422d978c 100644 --- a/packages/rspeedy/create-rspeedy/template-react-js/src/App.jsx +++ b/packages/rspeedy/create-rspeedy/template-react-js/src/App.jsx @@ -4,9 +4,11 @@ import './App.css' import arrow from './assets/arrow.png' import lynxLogo from './assets/lynx-logo.png' import reactLynxLogo from './assets/react-logo.png' +import { useFlappy } from './useFlappy.js' export function App() { const [alterLogo, setAlterLogo] = useState(false) + const [logoY, jump] = useFlappy() useEffect(() => { console.info('Hello, ReactLynx') @@ -18,11 +20,15 @@ export function App() { }, []) return ( - + - + {alterLogo ? : } diff --git a/packages/rspeedy/create-rspeedy/template-react-js/src/lib/flappy.js b/packages/rspeedy/create-rspeedy/template-react-js/src/lib/flappy.js new file mode 100644 index 0000000000..450e8178be --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-js/src/lib/flappy.js @@ -0,0 +1,58 @@ +/** + * Framework-agnostic flappy-bird physics engine. + * + * Manages gravity, jump impulse, and a 60fps game loop. + * Wire it up to any UI framework by calling `jump()` on tap + * and reading `getY()` in the loop callback. + * + * @param {(y: number) => void} onUpdate + * @param {object} [options] + * @param {number} [options.gravity=0.6] + * @param {number} [options.jumpForce=-12] + * @param {number} [options.stackFactor=0.6] + * @param {number} [options.frameMs=16] + * @returns {{ jump: () => void, getY: () => number, destroy: () => void }} + */ +export function createFlappy(onUpdate, options = {}) { + const { + gravity = 0.6, + jumpForce = -12, + stackFactor = 0.6, + frameMs = 16, + } = options + + let y = 0 + let velocity = 0 + let timer = null + + function loop() { + velocity += gravity + y += velocity + if (y >= 0) { + y = 0 + velocity = 0 + timer = null + onUpdate(y) + return + } + onUpdate(y) + timer = setTimeout(loop, frameMs) + } + + function jump() { + // Stack impulse on rapid taps, clamped to one full jumpForce + velocity = Math.max(velocity + jumpForce * stackFactor, jumpForce) + if (!timer) { + loop() + } + } + + function destroy() { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + return { jump, getY: () => y, destroy } +} diff --git a/packages/rspeedy/create-rspeedy/template-react-js/src/useFlappy.js b/packages/rspeedy/create-rspeedy/template-react-js/src/useFlappy.js new file mode 100644 index 0000000000..7c264f19cf --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-js/src/useFlappy.js @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from '@lynx-js/react' + +import { createFlappy } from './lib/flappy.js' + +/** + * React hook for flappy-bird physics. + * + * Returns `[y, jump]` — a state value and a stable callback. + * The game loop runs automatically; cleanup happens on unmount. + * Options are read once on mount and not reactive to later changes. + * + * @param {object} [options] + * @returns {[number, () => void]} + */ +export function useFlappy(options) { + const [y, setY] = useState(0) + const engineRef = useRef(null) + + if (!engineRef.current) { + engineRef.current = createFlappy((newY) => { + setY(newY) + }, options) + } + + useEffect(() => { + return () => { + engineRef.current?.destroy() + } + }, []) + + const jump = useCallback(() => { + 'background only' + engineRef.current?.jump() + }, []) + + return [y, jump] +} diff --git a/packages/rspeedy/create-rspeedy/template-react-ts/src/App.tsx b/packages/rspeedy/create-rspeedy/template-react-ts/src/App.tsx index 00ba284ab7..e5422d978c 100644 --- a/packages/rspeedy/create-rspeedy/template-react-ts/src/App.tsx +++ b/packages/rspeedy/create-rspeedy/template-react-ts/src/App.tsx @@ -4,9 +4,11 @@ import './App.css' import arrow from './assets/arrow.png' import lynxLogo from './assets/lynx-logo.png' import reactLynxLogo from './assets/react-logo.png' +import { useFlappy } from './useFlappy.js' export function App() { const [alterLogo, setAlterLogo] = useState(false) + const [logoY, jump] = useFlappy() useEffect(() => { console.info('Hello, ReactLynx') @@ -18,11 +20,15 @@ export function App() { }, []) return ( - + - + {alterLogo ? : } diff --git a/packages/rspeedy/create-rspeedy/template-react-ts/src/lib/flappy.ts b/packages/rspeedy/create-rspeedy/template-react-ts/src/lib/flappy.ts new file mode 100644 index 0000000000..740598413a --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-ts/src/lib/flappy.ts @@ -0,0 +1,76 @@ +/** + * Framework-agnostic flappy-bird physics engine. + * + * Manages gravity, jump impulse, and a 60fps game loop. + * Wire it up to any UI framework by calling `jump()` on tap + * and reading `getY()` in the loop callback. + */ + +export interface FlappyOptions { + /** Downward acceleration per frame (default 0.6) */ + gravity?: number + /** Upward impulse per tap — negative value (default -12) */ + jumpForce?: number + /** Impulse stacking factor for rapid taps (default 0.6) */ + stackFactor?: number + /** Frame interval in ms (default 16 ≈ 60fps) */ + frameMs?: number +} + +export type OnUpdate = (y: number) => void + +export interface FlappyEngine { + /** Call on each tap to apply upward impulse. */ + jump(): void + /** Current Y offset (0 = ground, negative = airborne). */ + getY(): number + /** Stop the game loop and clean up. */ + destroy(): void +} + +export function createFlappy( + onUpdate: OnUpdate, + options: FlappyOptions = {}, +): FlappyEngine { + const { + gravity = 0.6, + jumpForce = -12, + stackFactor = 0.6, + frameMs = 16, + } = options + + let y = 0 + let velocity = 0 + let timer: ReturnType | null = null + + function loop() { + velocity += gravity + y += velocity + if (y >= 0) { + y = 0 + velocity = 0 + timer = null + onUpdate(y) + return + } + onUpdate(y) + timer = setTimeout(loop, frameMs) + } + + function jump() { + // Stack impulse on rapid taps, clamped to one full jumpForce + velocity = Math.max(velocity + jumpForce * stackFactor, jumpForce) + if (!timer) { + loop() + } + } + + function destroy() { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + return { jump, getY: () => y, destroy } +} diff --git a/packages/rspeedy/create-rspeedy/template-react-ts/src/useFlappy.ts b/packages/rspeedy/create-rspeedy/template-react-ts/src/useFlappy.ts new file mode 100644 index 0000000000..50438ad415 --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-ts/src/useFlappy.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from '@lynx-js/react' + +import { createFlappy } from './lib/flappy.js' +import type { FlappyEngine, FlappyOptions } from './lib/flappy.js' + +/** + * React hook for flappy-bird physics. + * + * Returns `[y, jump]` — a state value and a stable callback. + * The game loop runs automatically; cleanup happens on unmount. + * Options are read once on mount and not reactive to later changes. + * + * @example + * ```tsx + * function Bird() { + * const [y, jump] = useFlappy() + * return ( + * + * Tap me! + * + * ) + * } + * ``` + */ +export function useFlappy( + options?: FlappyOptions, +): [number, () => void] { + const [y, setY] = useState(0) + const engineRef = useRef(null) + + if (!engineRef.current) { + engineRef.current = createFlappy((newY) => { + setY(newY) + }, options) + } + + useEffect(() => { + return () => { + engineRef.current?.destroy() + } + }, []) + + const jump = useCallback(() => { + 'background only' + engineRef.current?.jump() + }, []) + + return [y, jump] +} diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx index f575e34afc..3e7fb56435 100644 --- a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx +++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx @@ -4,9 +4,11 @@ import './App.css' import arrow from './assets/arrow.png' import lynxLogo from './assets/lynx-logo.png' import reactLynxLogo from './assets/react-logo.png' +import { useFlappy } from './useFlappy.js' export function App(props) { const [alterLogo, setAlterLogo] = useState(false) + const [logoY, jump] = useFlappy() useEffect(() => { console.info('Hello, ReactLynx') @@ -19,11 +21,15 @@ export function App(props) { }, []) return ( - + - + {alterLogo ? : } diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx index 0f68f79d74..ccc4159057 100644 --- a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx +++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx @@ -39,6 +39,7 @@ test('App', async () => { >