From 98958be37218f2d4d0d8ed784f8d08c959bacc47 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 18 Aug 2026 09:49:42 +0700 Subject: [PATCH] refactor: migrate quiz-project, gstack, and superpowers to JS+JSDoc Convert all 41 TypeScript source/config files in cc4e-course/quiz-project (Next.js), gstack (Astro), and superpowers (Vite/React/Phaser) to plain JavaScript with JSDoc type annotations, replacing tsconfig.json with jsconfig.json (strict + checkJs) in each subproject. No behavior changes: lint, typecheck, test, and build gates match their TypeScript baselines exactly in all three subprojects. Update stack references in the affected READMEs accordingly. --- README.md | 2 +- cc4e-course/quiz-project/README.md | 2 +- .../{QuizQuestion.tsx => QuizQuestion.jsx} | 33 ++- .../{QuizResults.tsx => QuizResults.jsx} | 28 +- .../app/{layout.tsx => layout.jsx} | 14 +- .../quiz-project/app/{page.tsx => page.jsx} | 49 ++-- cc4e-course/quiz-project/eslint.config.mjs | 3 - .../{tsconfig.json => jsconfig.json} | 14 +- cc4e-course/quiz-project/next-env.d.ts | 6 - .../{next.config.ts => next.config.mjs} | 5 +- cc4e-course/quiz-project/package.json | 3 +- gstack/README.md | 4 +- gstack/{tsconfig.json => jsconfig.json} | 3 +- .../{congruence-sss.ts => congruence-sss.js} | 142 ++++++---- ...{inscribed-angle.ts => inscribed-angle.js} | 78 ++++-- gstack/src/components/similarity-scale.js | 243 ++++++++++++++++++ gstack/src/components/similarity-scale.ts | 208 --------------- .../src/geom-engine/{circle.ts => circle.js} | 44 +++- .../{circle.test.ts => circle.test.js} | 2 +- gstack/src/geom-engine/triangle.js | 53 ++++ .../{triangle.test.ts => triangle.test.js} | 0 gstack/src/geom-engine/triangle.ts | 40 --- gstack/src/geom-engine/vec.js | 90 +++++++ .../geom-engine/{vec.test.ts => vec.test.js} | 0 gstack/src/geom-engine/vec.ts | 47 ---- gstack/src/i18n/index.js | 13 + gstack/src/i18n/index.ts | 11 - gstack/src/i18n/{vi.ts => vi.js} | 6 +- gstack/src/pages/index.astro | 6 +- gstack/{vitest.config.ts => vitest.config.js} | 6 +- superpowers/index.html | 2 +- superpowers/jsconfig.json | 35 +++ superpowers/package.json | 5 +- .../src/components/{App.tsx => App.jsx} | 14 +- ...ficultySelect.tsx => DifficultySelect.jsx} | 20 +- .../{GameContainer.tsx => GameContainer.jsx} | 24 +- .../components/{GameOver.tsx => GameOver.jsx} | 22 +- .../src/components/{HUD.tsx => HUD.jsx} | 15 +- .../src/components/{Menu.tsx => Menu.jsx} | 13 +- .../src/components/{Toast.tsx => Toast.jsx} | 17 +- superpowers/src/game/{board.ts => board.js} | 54 +++- .../src/game/{constants.ts => constants.js} | 8 +- superpowers/src/game/{emoji.ts => emoji.js} | 12 +- .../src/game/{pathfinder.ts => pathfinder.js} | 91 ++++--- .../src/game/{scoring.ts => scoring.js} | 10 +- superpowers/src/game/{state.ts => state.js} | 99 ++++--- superpowers/src/{main.tsx => main.jsx} | 2 +- .../src/phaser/{config.ts => config.js} | 12 +- .../scenes/{GameScene.ts => GameScene.js} | 142 +++++++--- .../{PreloadScene.ts => PreloadScene.js} | 6 +- .../src/types/{index.ts => index.d.ts} | 0 .../game/{board.test.ts => board.test.js} | 6 +- .../game/{emoji.test.ts => emoji.test.js} | 0 ...{pathfinder.test.ts => pathfinder.test.js} | 19 +- .../game/{scoring.test.ts => scoring.test.js} | 0 .../game/{state.test.ts => state.test.js} | 3 +- superpowers/tsconfig.app.json | 26 -- superpowers/tsconfig.json | 7 - superpowers/tsconfig.node.json | 24 -- .../{vite.config.ts => vite.config.js} | 0 60 files changed, 1115 insertions(+), 728 deletions(-) rename cc4e-course/quiz-project/app/components/{QuizQuestion.tsx => QuizQuestion.jsx} (75%) rename cc4e-course/quiz-project/app/components/{QuizResults.tsx => QuizResults.jsx} (83%) rename cc4e-course/quiz-project/app/{layout.tsx => layout.jsx} (71%) rename cc4e-course/quiz-project/app/{page.tsx => page.jsx} (73%) rename cc4e-course/quiz-project/{tsconfig.json => jsconfig.json} (72%) delete mode 100644 cc4e-course/quiz-project/next-env.d.ts rename cc4e-course/quiz-project/{next.config.ts => next.config.mjs} (74%) rename gstack/{tsconfig.json => jsconfig.json} (84%) rename gstack/src/components/{congruence-sss.ts => congruence-sss.js} (58%) rename gstack/src/components/{inscribed-angle.ts => inscribed-angle.js} (53%) create mode 100644 gstack/src/components/similarity-scale.js delete mode 100644 gstack/src/components/similarity-scale.ts rename gstack/src/geom-engine/{circle.ts => circle.js} (52%) rename gstack/src/geom-engine/{circle.test.ts => circle.test.js} (98%) create mode 100644 gstack/src/geom-engine/triangle.js rename gstack/src/geom-engine/{triangle.test.ts => triangle.test.js} (100%) delete mode 100644 gstack/src/geom-engine/triangle.ts create mode 100644 gstack/src/geom-engine/vec.js rename gstack/src/geom-engine/{vec.test.ts => vec.test.js} (100%) delete mode 100644 gstack/src/geom-engine/vec.ts create mode 100644 gstack/src/i18n/index.js delete mode 100644 gstack/src/i18n/index.ts rename gstack/src/i18n/{vi.ts => vi.js} (98%) rename gstack/{vitest.config.ts => vitest.config.js} (66%) create mode 100644 superpowers/jsconfig.json rename superpowers/src/components/{App.tsx => App.jsx} (84%) rename superpowers/src/components/{DifficultySelect.tsx => DifficultySelect.jsx} (77%) rename superpowers/src/components/{GameContainer.tsx => GameContainer.jsx} (72%) rename superpowers/src/components/{GameOver.tsx => GameOver.jsx} (83%) rename superpowers/src/components/{HUD.tsx => HUD.jsx} (90%) rename superpowers/src/components/{Menu.tsx => Menu.jsx} (78%) rename superpowers/src/components/{Toast.tsx => Toast.jsx} (70%) rename superpowers/src/game/{board.ts => board.js} (68%) rename superpowers/src/game/{constants.ts => constants.js} (68%) rename superpowers/src/game/{emoji.ts => emoji.js} (75%) rename superpowers/src/game/{pathfinder.ts => pathfinder.js} (65%) rename superpowers/src/game/{scoring.ts => scoring.js} (68%) rename superpowers/src/game/{state.ts => state.js} (54%) rename superpowers/src/{main.tsx => main.jsx} (69%) rename superpowers/src/phaser/{config.ts => config.js} (65%) rename superpowers/src/phaser/scenes/{GameScene.ts => GameScene.js} (77%) rename superpowers/src/phaser/scenes/{PreloadScene.ts => PreloadScene.js} (75%) rename superpowers/src/types/{index.ts => index.d.ts} (100%) rename superpowers/tests/game/{board.test.ts => board.test.js} (94%) rename superpowers/tests/game/{emoji.test.ts => emoji.test.js} (100%) rename superpowers/tests/game/{pathfinder.test.ts => pathfinder.test.js} (84%) rename superpowers/tests/game/{scoring.test.ts => scoring.test.js} (100%) rename superpowers/tests/game/{state.test.ts => state.test.js} (98%) delete mode 100644 superpowers/tsconfig.app.json delete mode 100644 superpowers/tsconfig.json delete mode 100644 superpowers/tsconfig.node.json rename superpowers/{vite.config.ts => vite.config.js} (100%) diff --git a/README.md b/README.md index 9f7d66d..f286581 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ together at **[miti99dev.github.io/ai-coding-workflow-labs](https://miti99dev.gi | Directory | Experiment | Demo | | --- | --- | --- | | [`claudekit/`](./claudekit/) | ClaudeKit workflow (Vite/JS) | [/claudekit/](https://miti99dev.github.io/ai-coding-workflow-labs/claudekit/) | -| [`superpowers/`](./superpowers/) | Superpowers skill set (Vite/TS) | [/superpowers/](https://miti99dev.github.io/ai-coding-workflow-labs/superpowers/) | +| [`superpowers/`](./superpowers/) | Superpowers skill set (Vite/JS) | [/superpowers/](https://miti99dev.github.io/ai-coding-workflow-labs/superpowers/) | | [`bmad/`](./bmad/) | BMAD method (Vite/JS) | [/bmad/](https://miti99dev.github.io/ai-coding-workflow-labs/bmad/) | | [`oh-my-claudecode/`](./oh-my-claudecode/) | oh-my-claudecode (static) | [/oh-my-claudecode/](https://miti99dev.github.io/ai-coding-workflow-labs/oh-my-claudecode/) | | [`gstack/`](./gstack/) | gstack starter (Astro/Bun) | [/gstack/](https://miti99dev.github.io/ai-coding-workflow-labs/gstack/) | diff --git a/cc4e-course/quiz-project/README.md b/cc4e-course/quiz-project/README.md index e215bc4..93ee04b 100644 --- a/cc4e-course/quiz-project/README.md +++ b/cc4e-course/quiz-project/README.md @@ -16,7 +16,7 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +You can start editing the page by modifying `app/page.jsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. diff --git a/cc4e-course/quiz-project/app/components/QuizQuestion.tsx b/cc4e-course/quiz-project/app/components/QuizQuestion.jsx similarity index 75% rename from cc4e-course/quiz-project/app/components/QuizQuestion.tsx rename to cc4e-course/quiz-project/app/components/QuizQuestion.jsx index 0a235a2..79965d4 100644 --- a/cc4e-course/quiz-project/app/components/QuizQuestion.tsx +++ b/cc4e-course/quiz-project/app/components/QuizQuestion.jsx @@ -1,17 +1,16 @@ -type Option = { - text: string; - personality: 'zen' | 'nightOwl' | 'socialButterfly'; -}; +/** @typedef {{ text: string, personality: 'zen' | 'nightOwl' | 'socialButterfly' }} Option */ -type Props = { - question: string; - options: Option[]; - current: number; - total: number; - onSelect: (personality: Option['personality']) => void; -}; - -export default function QuizQuestion({ question, options, current, total, onSelect }: Props) { +/** + * @param {{ + * question: string, + * options: Option[], + * current: number, + * total: number, + * onSelect: (personality: Option['personality']) => void, + * }} props + * @returns {import('react').JSX.Element} + */ +export default function QuizQuestion({ question, options, current, total, onSelect }) { return (
{ - (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--accent)'; - (e.currentTarget as HTMLButtonElement).style.background = 'var(--accent-light)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--accent)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.background = 'var(--accent-light)'; }} onMouseLeave={e => { - (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--border)'; - (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--border)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.background = 'transparent'; }} > {option.text} diff --git a/cc4e-course/quiz-project/app/components/QuizResults.tsx b/cc4e-course/quiz-project/app/components/QuizResults.jsx similarity index 83% rename from cc4e-course/quiz-project/app/components/QuizResults.tsx rename to cc4e-course/quiz-project/app/components/QuizResults.jsx index dc9d62c..0b0fa45 100644 --- a/cc4e-course/quiz-project/app/components/QuizResults.tsx +++ b/cc4e-course/quiz-project/app/components/QuizResults.jsx @@ -1,13 +1,9 @@ -type PersonalityKey = 'zen' | 'nightOwl' | 'socialButterfly'; +/** @typedef {'zen' | 'nightOwl' | 'socialButterfly'} PersonalityKey */ -type Scores = Record; +/** @typedef {Record} Scores */ -type Props = { - scores: Scores; - onReset: () => void; -}; - -const personalities: Record = { +/** @type {Record} */ +const personalities = { zen: { name: 'Zen Minimalist', coffee: 'Black Coffee, Single Origin', @@ -25,10 +21,14 @@ const personalities: Record void }} props + * @returns {import('react').JSX.Element} + */ +export default function QuizResults({ scores, onReset }) { const total = Object.values(scores).reduce((a, b) => a + b, 0); - const ranked = (Object.keys(scores) as PersonalityKey[]) + const ranked = (/** @type {PersonalityKey[]} */ (Object.keys(scores))) .map(key => ({ key, count: scores[key], @@ -134,12 +134,12 @@ export default function QuizResults({ scores, onReset }: Props) { transition: 'all 0.18s ease', }} onMouseEnter={e => { - (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--accent)'; - (e.currentTarget as HTMLButtonElement).style.color = 'var(--foreground)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--accent)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.color = 'var(--foreground)'; }} onMouseLeave={e => { - (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--border)'; - (e.currentTarget as HTMLButtonElement).style.color = 'var(--muted)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.borderColor = 'var(--border)'; + (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.color = 'var(--muted)'; }} > Take it again diff --git a/cc4e-course/quiz-project/app/layout.tsx b/cc4e-course/quiz-project/app/layout.jsx similarity index 71% rename from cc4e-course/quiz-project/app/layout.tsx rename to cc4e-course/quiz-project/app/layout.jsx index 6eff987..8049aff 100644 --- a/cc4e-course/quiz-project/app/layout.tsx +++ b/cc4e-course/quiz-project/app/layout.jsx @@ -1,4 +1,3 @@ -import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; @@ -12,16 +11,17 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { +/** @type {import('next').Metadata} */ +export const metadata = { title: "Coffee Personality Quiz | Basecamp Coffee", description: "Discover your coffee personality and find your perfect Basecamp Coffee drink.", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +/** + * @param {Readonly<{ children: import('react').ReactNode }>} props + * @returns {import('react').JSX.Element} + */ +export default function RootLayout({ children }) { return ( = { zen: 0, nightOwl: 0, socialButterfly: 0 }; +/** @type {Record} */ +const initialScores = { zen: 0, nightOwl: 0, socialButterfly: 0 }; +/** + * @returns {import('react').JSX.Element} + */ export default function Home() { - const [stage, setStage] = useState('intro'); + const [stage, setStage] = useState(/** @type {Stage} */ ('intro')); const [currentQ, setCurrentQ] = useState(0); const [scores, setScores] = useState({ ...initialScores }); - function handleAnswer(personality: PersonalityKey) { + /** @param {PersonalityKey} personality */ + function handleAnswer(personality) { const newScores = { ...scores, [personality]: scores[personality] + 1 }; setScores(newScores); @@ -149,8 +154,8 @@ export default function Home() { transition: 'opacity 0.18s ease', width: '100%', }} - onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.opacity = '0.88'; }} - onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.opacity = '1'; }} + onMouseEnter={e => { (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.opacity = '0.88'; }} + onMouseLeave={e => { (/** @type {HTMLButtonElement} */ (e.currentTarget)).style.opacity = '1'; }} > Find my coffee → diff --git a/cc4e-course/quiz-project/eslint.config.mjs b/cc4e-course/quiz-project/eslint.config.mjs index 05e726d..a10ed75 100644 --- a/cc4e-course/quiz-project/eslint.config.mjs +++ b/cc4e-course/quiz-project/eslint.config.mjs @@ -1,17 +1,14 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, - ...nextTs, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", - "next-env.d.ts", ]), ]); diff --git a/cc4e-course/quiz-project/tsconfig.json b/cc4e-course/quiz-project/jsconfig.json similarity index 72% rename from cc4e-course/quiz-project/tsconfig.json rename to cc4e-course/quiz-project/jsconfig.json index 3a13f90..119a58f 100644 --- a/cc4e-course/quiz-project/tsconfig.json +++ b/cc4e-course/quiz-project/jsconfig.json @@ -3,6 +3,7 @@ "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, + "checkJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -12,7 +13,6 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true, "plugins": [ { "name": "next" @@ -23,12 +23,10 @@ } }, "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.js", + "**/*.jsx", + "**/*.mjs", + ".next/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next", "out"] } diff --git a/cc4e-course/quiz-project/next-env.d.ts b/cc4e-course/quiz-project/next-env.d.ts deleted file mode 100644 index 9edff1c..0000000 --- a/cc4e-course/quiz-project/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/cc4e-course/quiz-project/next.config.ts b/cc4e-course/quiz-project/next.config.mjs similarity index 74% rename from cc4e-course/quiz-project/next.config.ts rename to cc4e-course/quiz-project/next.config.mjs index 095d225..475206c 100644 --- a/cc4e-course/quiz-project/next.config.ts +++ b/cc4e-course/quiz-project/next.config.mjs @@ -1,6 +1,5 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { +/** @type {import('next').NextConfig} */ +const nextConfig = { // Static export so the quiz can be served from GitHub Pages // under /ai-coding-workflow-labs/cc4e-course/. output: "export", diff --git a/cc4e-course/quiz-project/package.json b/cc4e-course/quiz-project/package.json index 8836535..fb37e57 100644 --- a/cc4e-course/quiz-project/package.json +++ b/cc4e-course/quiz-project/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc -p jsconfig.json" }, "dependencies": { "next": "16.2.11", diff --git a/gstack/README.md b/gstack/README.md index 3f75a6c..ccc439d 100644 --- a/gstack/README.md +++ b/gstack/README.md @@ -14,7 +14,7 @@ Scaffold only (v0.0.1.0). Modules are placeholders — landing page lists three npm install npm run dev # http://localhost:4321/try-gstack/ npm test # Vitest (geom-engine unit tests) -npm run typecheck # Astro check + TS strict +npm run typecheck # Astro check + strict checkJs (JavaScript + JSDoc) npm run build # Static output to dist/ ``` @@ -31,7 +31,7 @@ Auto-deploys to GitHub Pages from `main` via `actions/deploy-pages@v4`. See `RUN - **Math rendering**: KaTeX, bundled locally + SSR-rendered — to be added with first canvas module - **Animation**: Web Animations API + `requestAnimationFrame` (no GSAP / Motion One / Lottie) - **Analytics**: Cloudflare Web Analytics, deferred until 50+ daily sessions -- **i18n**: every string via `t()` from `src/i18n/vi.ts`; English added by adding `en.ts` +- **i18n**: every string via `t()` from `src/i18n/vi.js`; English added by adding `en.js` ## License diff --git a/gstack/tsconfig.json b/gstack/jsconfig.json similarity index 84% rename from gstack/tsconfig.json rename to gstack/jsconfig.json index 7fb10cf..7079006 100644 --- a/gstack/tsconfig.json +++ b/gstack/jsconfig.json @@ -5,7 +5,8 @@ "paths": { "~/*": ["src/*"] }, - "verbatimModuleSyntax": false + "allowJs": true, + "checkJs": true }, "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist", "node_modules"] diff --git a/gstack/src/components/congruence-sss.ts b/gstack/src/components/congruence-sss.js similarity index 58% rename from gstack/src/components/congruence-sss.ts rename to gstack/src/components/congruence-sss.js index 0ff62b4..518aa54 100644 --- a/gstack/src/components/congruence-sss.ts +++ b/gstack/src/components/congruence-sss.js @@ -1,11 +1,12 @@ import { vec } from '~/geom-engine/vec'; -import type { Vec2 } from '~/geom-engine/vec'; import { congruentSSS, sides, triangle as makeTriangle, } from '~/geom-engine/triangle'; +/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */ + const VIEW_W = 400; const VIEW_H = 300; const TICK_LEN = 6; @@ -15,11 +16,15 @@ const PAIR1 = '#D7263D'; const PAIR2 = '#1B998B'; const PAIR3 = '#F46036'; -type VertexId = 'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp'; +/** @typedef {'a' | 'b' | 'c' | 'ap' | 'bp' | 'cp'} VertexId */ -const VERTEX_IDS: readonly VertexId[] = ['a', 'b', 'c', 'ap', 'bp', 'cp']; +/** @typedef {'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap'} SideKey */ -const INITIAL: Record = { +/** @type {readonly VertexId[]} */ +const VERTEX_IDS = ['a', 'b', 'c', 'ap', 'bp', 'cp']; + +/** @type {Record} */ +const INITIAL = { a: vec(60, 80), b: vec(180, 80), c: vec(120, 220), @@ -28,36 +33,60 @@ const INITIAL: Record = { cp: vec(280, 220), }; -interface Refs { - svg: SVGSVGElement; - vertices: Record; - labels: Record; - sides: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGLineElement>; - ticks: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', SVGGElement>; - readouts: Record<'ab' | 'bc' | 'ca' | 'apbp' | 'bpcp' | 'cpap', HTMLElement>; - badge: HTMLElement; -} +/** + * @typedef {object} Refs + * @property {SVGSVGElement} svg + * @property {Record} vertices + * @property {Record} labels + * @property {Record} sides + * @property {Record} ticks + * @property {Record} readouts + * @property {HTMLElement} badge + */ -function clientToSvg(svg: SVGSVGElement, x: number, y: number): Vec2 { +/** + * @param {SVGSVGElement} svg + * @param {number} x + * @param {number} y + * @returns {Vec2} + */ +function clientToSvg(svg, x, y) { const r = svg.getBoundingClientRect(); return vec(((x - r.left) / r.width) * VIEW_W, ((y - r.top) / r.height) * VIEW_H); } -function clamp(v: Vec2, pad = 16): Vec2 { +/** + * @param {Vec2} v + * @param {number} [pad] + * @returns {Vec2} + */ +function clamp(v, pad = 16) { return vec( Math.max(pad, Math.min(VIEW_W - pad, v.x)), Math.max(pad, Math.min(VIEW_H - pad, v.y)), ); } -function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) { +/** + * @param {SVGLineElement} line + * @param {Vec2} p1 + * @param {Vec2} p2 + */ +function setLine(line, p1, p2) { line.setAttribute('x1', p1.x.toFixed(2)); line.setAttribute('y1', p1.y.toFixed(2)); line.setAttribute('x2', p2.x.toFixed(2)); line.setAttribute('y2', p2.y.toFixed(2)); } -function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, color: string) { +/** + * @param {SVGGElement} group + * @param {Vec2} p1 + * @param {Vec2} p2 + * @param {1 | 2 | 3} count + * @param {string} color + */ +function renderTicks(group, p1, p2, count, color) { while (group.firstChild) group.removeChild(group.firstChild); const dx = p2.x - p1.x; const dy = p2.y - p1.y; @@ -84,7 +113,11 @@ function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, c } } -function update(refs: Refs, state: Record) { +/** + * @param {Refs} refs + * @param {Record} state + */ +function update(refs, state) { for (const id of VERTEX_IDS) { const v = state[id]; refs.vertices[id].setAttribute('cx', v.x.toFixed(2)); @@ -126,64 +159,82 @@ function update(refs: Refs, state: Record) { refs.badge.style.display = congruent ? 'inline-block' : 'none'; } -function getRefs(svg: SVGSVGElement): Refs | null { - const vertices: Partial> = {}; - const labels: Partial> = {}; +/** + * @param {SVGSVGElement} svg + * @returns {Refs | null} + */ +function getRefs(svg) { + /** @type {Partial>} */ + const vertices = {}; + /** @type {Partial>} */ + const labels = {}; for (const id of VERTEX_IDS) { - const v = svg.querySelector(`[data-vertex="${id}"]`); - const l = svg.querySelector(`[data-vertex-label="${id}"]`); + const v = /** @type {SVGCircleElement | null} */ (svg.querySelector(`[data-vertex="${id}"]`)); + const l = /** @type {SVGTextElement | null} */ ( + svg.querySelector(`[data-vertex-label="${id}"]`) + ); if (!v || !l) return null; vertices[id] = v; labels[id] = l; } - const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'] as const; - type SideKey = (typeof sideKeys)[number]; - const sides: Partial> = {}; - const ticks: Partial> = {}; - const readouts: Partial> = {}; + /** @type {readonly SideKey[]} */ + const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap']; + /** @type {Partial>} */ + const sides = {}; + /** @type {Partial>} */ + const ticks = {}; + /** @type {Partial>} */ + const readouts = {}; for (const key of sideKeys) { - const s = svg.querySelector(`[data-side="${key}"]`); - const t = svg.querySelector(`[data-ticks="${key}"]`); - const r = document.querySelector(`[data-readout-side="${key}"]`); + const s = /** @type {SVGLineElement | null} */ (svg.querySelector(`[data-side="${key}"]`)); + const t = /** @type {SVGGElement | null} */ (svg.querySelector(`[data-ticks="${key}"]`)); + const r = /** @type {HTMLElement | null} */ ( + document.querySelector(`[data-readout-side="${key}"]`) + ); if (!s || !t || !r) return null; sides[key] = s; ticks[key] = t; readouts[key] = r; } - const badge = document.querySelector('[data-badge="congruent"]'); + const badge = /** @type {HTMLElement | null} */ ( + document.querySelector('[data-badge="congruent"]') + ); if (!badge) return null; return { svg, - vertices: vertices as Record, - labels: labels as Record, - sides: sides as Record, - ticks: ticks as Record, - readouts: readouts as Record, + vertices: /** @type {Record} */ (vertices), + labels: /** @type {Record} */ (labels), + sides: /** @type {Record} */ (sides), + ticks: /** @type {Record} */ (ticks), + readouts: /** @type {Record} */ (readouts), badge, }; } -export function setupCongruenceSSS(svgSelector: string) { - const svg = document.querySelector(svgSelector); +/** @param {string} svgSelector */ +export function setupCongruenceSSS(svgSelector) { + const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector)); if (!svg) return; const refs = getRefs(svg); if (!refs) return; - const state: Record = { ...INITIAL }; + /** @type {Record} */ + const state = { ...INITIAL }; update(refs, state); - let active: { id: number; vertex: VertexId } | null = null; + /** @type {{ id: number, vertex: VertexId } | null} */ + let active = null; const ctrl = new AbortController(); - const opts = { signal: ctrl.signal } as AddEventListenerOptions; + const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal }); for (const id of VERTEX_IDS) { const el = refs.vertices[id]; el.addEventListener( 'pointerdown', - (e: PointerEvent) => { + (e) => { active = { id: e.pointerId, vertex: id }; el.setPointerCapture(e.pointerId); e.preventDefault(); @@ -192,7 +243,7 @@ export function setupCongruenceSSS(svgSelector: string) { ); el.addEventListener( 'pointermove', - (e: PointerEvent) => { + (e) => { if (!active || active.id !== e.pointerId) return; const raw = clientToSvg(svg, e.clientX, e.clientY); state[active.vertex] = clamp(raw); @@ -200,7 +251,8 @@ export function setupCongruenceSSS(svgSelector: string) { }, opts, ); - const release = (e: PointerEvent) => { + /** @param {PointerEvent} e */ + const release = (e) => { if (!active || active.id !== e.pointerId) return; if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); active = null; diff --git a/gstack/src/components/inscribed-angle.ts b/gstack/src/components/inscribed-angle.js similarity index 53% rename from gstack/src/components/inscribed-angle.ts rename to gstack/src/components/inscribed-angle.js index aa2ccbd..7f30f66 100644 --- a/gstack/src/components/inscribed-angle.ts +++ b/gstack/src/components/inscribed-angle.js @@ -1,33 +1,48 @@ import { angleAtVertex, circle, pointOnCircle, projectToCircle } from '~/geom-engine/circle'; -import type { Vec2 } from '~/geom-engine/vec'; import { vec } from '~/geom-engine/vec'; +/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */ + const VIEW_SIZE = 400; const C = circle(VIEW_SIZE / 2, VIEW_SIZE / 2, 150); // A and B are fixed; M is draggable on the circle. -const A: Vec2 = pointOnCircle(C, 150); -const B: Vec2 = pointOnCircle(C, 30); -const M_INITIAL: Vec2 = pointOnCircle(C, 270); +/** @type {Vec2} */ +const A = pointOnCircle(C, 150); +/** @type {Vec2} */ +const B = pointOnCircle(C, 30); +/** @type {Vec2} */ +const M_INITIAL = pointOnCircle(C, 270); -interface Refs { - svg: SVGSVGElement; - m: SVGCircleElement; - segAM: SVGLineElement; - segBM: SVGLineElement; - inscribedReadout: HTMLElement; - centralReadout: HTMLElement; -} +/** + * @typedef {object} Refs + * @property {SVGSVGElement} svg + * @property {SVGCircleElement} m + * @property {SVGLineElement} segAM + * @property {SVGLineElement} segBM + * @property {HTMLElement} inscribedReadout + * @property {HTMLElement} centralReadout + */ -function clientToSvg(svg: SVGSVGElement, clientX: number, clientY: number): Vec2 { - // Convert a clientX/clientY coordinate to the SVG's viewBox space. +/** + * Convert a clientX/clientY coordinate to the SVG's viewBox space. + * @param {SVGSVGElement} svg + * @param {number} clientX + * @param {number} clientY + * @returns {Vec2} + */ +function clientToSvg(svg, clientX, clientY) { const rect = svg.getBoundingClientRect(); const x = ((clientX - rect.left) / rect.width) * VIEW_SIZE; const y = ((clientY - rect.top) / rect.height) * VIEW_SIZE; return vec(x, y); } -function update(refs: Refs, m: Vec2) { +/** + * @param {Refs} refs + * @param {Vec2} m + */ +function update(refs, m) { refs.m.setAttribute('cx', m.x.toFixed(2)); refs.m.setAttribute('cy', m.y.toFixed(2)); refs.segAM.setAttribute('x2', m.x.toFixed(2)); @@ -42,43 +57,52 @@ function update(refs: Refs, m: Vec2) { refs.centralReadout.textContent = `${central.toFixed(1)}°`; } -export function setupInscribedAngle(svgSelector: string) { - const svg = document.querySelector(svgSelector); +/** @param {string} svgSelector */ +export function setupInscribedAngle(svgSelector) { + const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector)); if (!svg) return; - const m = svg.querySelector('[data-vertex="M"]'); - const segAM = svg.querySelector('[data-segment="AM"]'); - const segBM = svg.querySelector('[data-segment="BM"]'); - const inscribedReadout = document.querySelector('[data-readout="inscribed"]'); - const centralReadout = document.querySelector('[data-readout="central"]'); + const m = /** @type {SVGCircleElement | null} */ (svg.querySelector('[data-vertex="M"]')); + const segAM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="AM"]')); + const segBM = /** @type {SVGLineElement | null} */ (svg.querySelector('[data-segment="BM"]')); + const inscribedReadout = /** @type {HTMLElement | null} */ ( + document.querySelector('[data-readout="inscribed"]') + ); + const centralReadout = /** @type {HTMLElement | null} */ ( + document.querySelector('[data-readout="central"]') + ); if (!m || !segAM || !segBM || !inscribedReadout || !centralReadout) return; - const refs: Refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout }; + /** @type {Refs} */ + const refs = { svg, m, segAM, segBM, inscribedReadout, centralReadout }; let active = false; // Render initial state once. update(refs, M_INITIAL); - const onPointerDown = (e: PointerEvent) => { + /** @param {PointerEvent} e */ + const onPointerDown = (e) => { active = true; m.setPointerCapture(e.pointerId); e.preventDefault(); }; - const onPointerMove = (e: PointerEvent) => { + /** @param {PointerEvent} e */ + const onPointerMove = (e) => { if (!active) return; const raw = clientToSvg(svg, e.clientX, e.clientY); const projected = projectToCircle(raw, C); update(refs, projected); }; - const onPointerUp = (e: PointerEvent) => { + /** @param {PointerEvent} e */ + const onPointerUp = (e) => { if (!active) return; active = false; if (m.hasPointerCapture(e.pointerId)) m.releasePointerCapture(e.pointerId); }; const ctrl = new AbortController(); - const opts = { signal: ctrl.signal } as AddEventListenerOptions; + const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal }); m.addEventListener('pointerdown', onPointerDown, opts); m.addEventListener('pointermove', onPointerMove, opts); diff --git a/gstack/src/components/similarity-scale.js b/gstack/src/components/similarity-scale.js new file mode 100644 index 0000000..f0d87e4 --- /dev/null +++ b/gstack/src/components/similarity-scale.js @@ -0,0 +1,243 @@ +import { add, scale, sub, vec } from '~/geom-engine/vec'; +import { angleAtVertex } from '~/geom-engine/circle'; +import { sides, triangle as makeTriangle } from '~/geom-engine/triangle'; + +/** @typedef {import('~/geom-engine/vec').Vec2} Vec2 */ +/** @typedef {import('~/geom-engine/triangle').Triangle} Triangle */ + +const VIEW_W = 400; +const VIEW_H = 300; + +const PAIR1 = '#D7263D'; +const PAIR2 = '#1B998B'; +const PAIR3 = '#F46036'; + +// Scalene triangle; centroid at (101.67, 146.67) — close to (100, 147). +/** @type {Vec2} */ +const A = vec(70, 110); +/** @type {Vec2} */ +const B = vec(140, 130); +/** @type {Vec2} */ +const C = vec(95, 200); +/** @type {Vec2} */ +const CENTROID_ABC = vec( + (A.x + B.x + C.x) / 3, + (A.y + B.y + C.y) / 3, +); +/** @type {Vec2} */ +const CENTROID_TARGET = vec(300, 145); + +/** + * @param {number} k + * @returns {Triangle} + */ +function scaledTriangle(k) { + /** @param {Vec2} p */ + const make = (p) => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k)); + return makeTriangle(make(A), make(B), make(C)); +} + +/** + * @typedef {object} Refs + * @property {SVGCircleElement} ap + * @property {SVGCircleElement} bp + * @property {SVGCircleElement} cp + * @property {SVGTextElement} apLabel + * @property {SVGTextElement} bpLabel + * @property {SVGTextElement} cpLabel + * @property {SVGLineElement} apbp + * @property {SVGLineElement} bpcp + * @property {SVGLineElement} cpap + * @property {SVGGElement} tickApBp Tick group for triangle 2 + * @property {SVGGElement} tickBpCp Tick group for triangle 2 + * @property {SVGGElement} tickCpAp Tick group for triangle 2 + * @property {HTMLElement} kReadout k display + * @property {HTMLInputElement} kSlider k display + * @property {HTMLElement} apbpReadout Side-length & ratio readouts + * @property {HTMLElement} bpcpReadout Side-length & ratio readouts + * @property {HTMLElement} cpapReadout Side-length & ratio readouts + * @property {HTMLElement} ratioAB Side-length & ratio readouts + * @property {HTMLElement} ratioBC Side-length & ratio readouts + * @property {HTMLElement} ratioCA Side-length & ratio readouts + */ + +const TICK_LEN = 6; +const TICK_SPACING = 5; + +/** + * @param {SVGLineElement} line + * @param {Vec2} p1 + * @param {Vec2} p2 + */ +function setLine(line, p1, p2) { + line.setAttribute('x1', p1.x.toFixed(2)); + line.setAttribute('y1', p1.y.toFixed(2)); + line.setAttribute('x2', p2.x.toFixed(2)); + line.setAttribute('y2', p2.y.toFixed(2)); +} + +/** + * @param {SVGGElement} group + * @param {Vec2} p1 + * @param {Vec2} p2 + * @param {1 | 2 | 3} count + * @param {string} color + */ +function renderTicks(group, p1, p2, count, color) { + while (group.firstChild) group.removeChild(group.firstChild); + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + if (len < 1) return; + const dir = vec(dx / len, dy / len); + const perp = vec(-dir.y, dir.x); + const mid = vec((p1.x + p2.x) / 2, (p1.y + p2.y) / 2); + const start = -((count - 1) * TICK_SPACING) / 2; + for (let i = 0; i < count; i++) { + const offset = start + i * TICK_SPACING; + const cx = mid.x + dir.x * offset; + const cy = mid.y + dir.y * offset; + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', (cx + perp.x * TICK_LEN).toFixed(2)); + line.setAttribute('y1', (cy + perp.y * TICK_LEN).toFixed(2)); + line.setAttribute('x2', (cx - perp.x * TICK_LEN).toFixed(2)); + line.setAttribute('y2', (cy - perp.y * TICK_LEN).toFixed(2)); + line.setAttribute('stroke', color); + line.setAttribute('stroke-width', '2.5'); + line.setAttribute('stroke-linecap', 'round'); + group.appendChild(line); + } +} + +/** + * @param {Refs} refs + * @param {number} k + */ +function update(refs, k) { + const t2 = scaledTriangle(k); + + refs.ap.setAttribute('cx', t2.a.x.toFixed(2)); + refs.ap.setAttribute('cy', t2.a.y.toFixed(2)); + refs.bp.setAttribute('cx', t2.b.x.toFixed(2)); + refs.bp.setAttribute('cy', t2.b.y.toFixed(2)); + refs.cp.setAttribute('cx', t2.c.x.toFixed(2)); + refs.cp.setAttribute('cy', t2.c.y.toFixed(2)); + + refs.apLabel.setAttribute('x', (t2.a.x - 16).toFixed(2)); + refs.apLabel.setAttribute('y', (t2.a.y - 10).toFixed(2)); + refs.bpLabel.setAttribute('x', (t2.b.x + 8).toFixed(2)); + refs.bpLabel.setAttribute('y', (t2.b.y - 10).toFixed(2)); + refs.cpLabel.setAttribute('x', (t2.c.x - 6).toFixed(2)); + refs.cpLabel.setAttribute('y', (t2.c.y + 22).toFixed(2)); + + setLine(refs.apbp, t2.a, t2.b); + setLine(refs.bpcp, t2.b, t2.c); + setLine(refs.cpap, t2.c, t2.a); + + renderTicks(refs.tickApBp, t2.a, t2.b, 1, PAIR1); + renderTicks(refs.tickBpCp, t2.b, t2.c, 2, PAIR2); + renderTicks(refs.tickCpAp, t2.c, t2.a, 3, PAIR3); + + const s2 = sides(t2); + refs.apbpReadout.textContent = s2.ab.toFixed(1); + refs.bpcpReadout.textContent = s2.bc.toFixed(1); + refs.cpapReadout.textContent = s2.ca.toFixed(1); + + // Ratios AB/A'B' = 1/k. Display ALL three to show they stay equal. + const ratio = 1 / k; + const ratioStr = ratio.toFixed(2); + refs.ratioAB.textContent = ratioStr; + refs.ratioBC.textContent = ratioStr; + refs.ratioCA.textContent = ratioStr; + + refs.kReadout.textContent = k.toFixed(2); +} + +/** + * @param {SVGSVGElement} svg + * @returns {Refs | null} + */ +function getRefs(svg) { + /** + * @template {Element} T + * @param {string} sel + * @param {ParentNode} [root] + * @returns {T | null} + */ + const q = (sel, root = document) => /** @type {T | null} */ (root.querySelector(sel)); + + const ap = /** @type {SVGCircleElement | null} */ (q('[data-vertex="ap"]', svg)); + const bp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="bp"]', svg)); + const cp = /** @type {SVGCircleElement | null} */ (q('[data-vertex="cp"]', svg)); + const apLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="ap"]', svg)); + const bpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="bp"]', svg)); + const cpLabel = /** @type {SVGTextElement | null} */ (q('[data-vertex-label="cp"]', svg)); + const apbp = /** @type {SVGLineElement | null} */ (q('[data-side="apbp"]', svg)); + const bpcp = /** @type {SVGLineElement | null} */ (q('[data-side="bpcp"]', svg)); + const cpap = /** @type {SVGLineElement | null} */ (q('[data-side="cpap"]', svg)); + const tickApBp = /** @type {SVGGElement | null} */ (q('[data-ticks="apbp"]', svg)); + const tickBpCp = /** @type {SVGGElement | null} */ (q('[data-ticks="bpcp"]', svg)); + const tickCpAp = /** @type {SVGGElement | null} */ (q('[data-ticks="cpap"]', svg)); + + const kReadout = /** @type {HTMLElement | null} */ (q('[data-readout="k"]')); + const kSlider = /** @type {HTMLInputElement | null} */ (q('[data-control="k-slider"]')); + const apbpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="apbp"]')); + const bpcpReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="bpcp"]')); + const cpapReadout = /** @type {HTMLElement | null} */ (q('[data-readout-side="cpap"]')); + const ratioAB = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="ab"]')); + const ratioBC = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="bc"]')); + const ratioCA = /** @type {HTMLElement | null} */ (q('[data-readout-ratio="ca"]')); + + if ( + !ap || !bp || !cp || !apLabel || !bpLabel || !cpLabel || + !apbp || !bpcp || !cpap || + !tickApBp || !tickBpCp || !tickCpAp || + !kReadout || !kSlider || + !apbpReadout || !bpcpReadout || !cpapReadout || + !ratioAB || !ratioBC || !ratioCA + ) return null; + + return { + ap, bp, cp, apLabel, bpLabel, cpLabel, + apbp, bpcp, cpap, + tickApBp, tickBpCp, tickCpAp, + kReadout, kSlider, + apbpReadout, bpcpReadout, cpapReadout, + ratioAB, ratioBC, ratioCA, + }; +} + +/** @param {string} svgSelector */ +export function setupSimilarityScale(svgSelector) { + const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector)); + if (!svg) return; + const refs = getRefs(svg); + if (!refs) return; + + const ctrl = new AbortController(); + const opts = /** @type {AddEventListenerOptions} */ ({ signal: ctrl.signal }); + + // Static ticks for triangle 1 (ABC) — render once, never change. + const tickAB = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="ab"]')); + const tickBC = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="bc"]')); + const tickCA = /** @type {SVGGElement | null} */ (svg.querySelector('[data-ticks="ca"]')); + if (tickAB && tickBC && tickCA) { + renderTicks(tickAB, A, B, 1, PAIR1); + renderTicks(tickBC, B, C, 2, PAIR2); + renderTicks(tickCA, C, A, 3, PAIR3); + } + + const initialK = parseFloat(refs.kSlider.value) || 1; + update(refs, initialK); + + refs.kSlider.addEventListener( + 'input', + () => { + const k = parseFloat(refs.kSlider.value); + if (Number.isFinite(k)) update(refs, k); + }, + opts, + ); + + document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true }); +} diff --git a/gstack/src/components/similarity-scale.ts b/gstack/src/components/similarity-scale.ts deleted file mode 100644 index 1d5d1ce..0000000 --- a/gstack/src/components/similarity-scale.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { add, scale, sub, vec } from '~/geom-engine/vec'; -import type { Vec2 } from '~/geom-engine/vec'; -import { angleAtVertex } from '~/geom-engine/circle'; -import { sides, triangle as makeTriangle } from '~/geom-engine/triangle'; -import type { Triangle } from '~/geom-engine/triangle'; - -const VIEW_W = 400; -const VIEW_H = 300; - -const PAIR1 = '#D7263D'; -const PAIR2 = '#1B998B'; -const PAIR3 = '#F46036'; - -// Scalene triangle; centroid at (101.67, 146.67) — close to (100, 147). -const A: Vec2 = vec(70, 110); -const B: Vec2 = vec(140, 130); -const C: Vec2 = vec(95, 200); -const CENTROID_ABC: Vec2 = vec( - (A.x + B.x + C.x) / 3, - (A.y + B.y + C.y) / 3, -); -const CENTROID_TARGET: Vec2 = vec(300, 145); - -function scaledTriangle(k: number): Triangle { - const make = (p: Vec2): Vec2 => add(CENTROID_TARGET, scale(sub(p, CENTROID_ABC), k)); - return makeTriangle(make(A), make(B), make(C)); -} - -interface Refs { - ap: SVGCircleElement; - bp: SVGCircleElement; - cp: SVGCircleElement; - apLabel: SVGTextElement; - bpLabel: SVGTextElement; - cpLabel: SVGTextElement; - apbp: SVGLineElement; - bpcp: SVGLineElement; - cpap: SVGLineElement; - // Tick groups for triangle 2 - tickApBp: SVGGElement; - tickBpCp: SVGGElement; - tickCpAp: SVGGElement; - // k display - kReadout: HTMLElement; - kSlider: HTMLInputElement; - // Side-length & ratio readouts - apbpReadout: HTMLElement; - bpcpReadout: HTMLElement; - cpapReadout: HTMLElement; - ratioAB: HTMLElement; - ratioBC: HTMLElement; - ratioCA: HTMLElement; -} - -const TICK_LEN = 6; -const TICK_SPACING = 5; - -function setLine(line: SVGLineElement, p1: Vec2, p2: Vec2) { - line.setAttribute('x1', p1.x.toFixed(2)); - line.setAttribute('y1', p1.y.toFixed(2)); - line.setAttribute('x2', p2.x.toFixed(2)); - line.setAttribute('y2', p2.y.toFixed(2)); -} - -function renderTicks(group: SVGGElement, p1: Vec2, p2: Vec2, count: 1 | 2 | 3, color: string) { - while (group.firstChild) group.removeChild(group.firstChild); - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy); - if (len < 1) return; - const dir = vec(dx / len, dy / len); - const perp = vec(-dir.y, dir.x); - const mid = vec((p1.x + p2.x) / 2, (p1.y + p2.y) / 2); - const start = -((count - 1) * TICK_SPACING) / 2; - for (let i = 0; i < count; i++) { - const offset = start + i * TICK_SPACING; - const cx = mid.x + dir.x * offset; - const cy = mid.y + dir.y * offset; - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', (cx + perp.x * TICK_LEN).toFixed(2)); - line.setAttribute('y1', (cy + perp.y * TICK_LEN).toFixed(2)); - line.setAttribute('x2', (cx - perp.x * TICK_LEN).toFixed(2)); - line.setAttribute('y2', (cy - perp.y * TICK_LEN).toFixed(2)); - line.setAttribute('stroke', color); - line.setAttribute('stroke-width', '2.5'); - line.setAttribute('stroke-linecap', 'round'); - group.appendChild(line); - } -} - -function update(refs: Refs, k: number) { - const t2 = scaledTriangle(k); - - refs.ap.setAttribute('cx', t2.a.x.toFixed(2)); - refs.ap.setAttribute('cy', t2.a.y.toFixed(2)); - refs.bp.setAttribute('cx', t2.b.x.toFixed(2)); - refs.bp.setAttribute('cy', t2.b.y.toFixed(2)); - refs.cp.setAttribute('cx', t2.c.x.toFixed(2)); - refs.cp.setAttribute('cy', t2.c.y.toFixed(2)); - - refs.apLabel.setAttribute('x', (t2.a.x - 16).toFixed(2)); - refs.apLabel.setAttribute('y', (t2.a.y - 10).toFixed(2)); - refs.bpLabel.setAttribute('x', (t2.b.x + 8).toFixed(2)); - refs.bpLabel.setAttribute('y', (t2.b.y - 10).toFixed(2)); - refs.cpLabel.setAttribute('x', (t2.c.x - 6).toFixed(2)); - refs.cpLabel.setAttribute('y', (t2.c.y + 22).toFixed(2)); - - setLine(refs.apbp, t2.a, t2.b); - setLine(refs.bpcp, t2.b, t2.c); - setLine(refs.cpap, t2.c, t2.a); - - renderTicks(refs.tickApBp, t2.a, t2.b, 1, PAIR1); - renderTicks(refs.tickBpCp, t2.b, t2.c, 2, PAIR2); - renderTicks(refs.tickCpAp, t2.c, t2.a, 3, PAIR3); - - const s2 = sides(t2); - refs.apbpReadout.textContent = s2.ab.toFixed(1); - refs.bpcpReadout.textContent = s2.bc.toFixed(1); - refs.cpapReadout.textContent = s2.ca.toFixed(1); - - // Ratios AB/A'B' = 1/k. Display ALL three to show they stay equal. - const ratio = 1 / k; - const ratioStr = ratio.toFixed(2); - refs.ratioAB.textContent = ratioStr; - refs.ratioBC.textContent = ratioStr; - refs.ratioCA.textContent = ratioStr; - - refs.kReadout.textContent = k.toFixed(2); -} - -function getRefs(svg: SVGSVGElement): Refs | null { - const q = (sel: string, root: ParentNode = document): T | null => - root.querySelector(sel); - - const ap = q('[data-vertex="ap"]', svg); - const bp = q('[data-vertex="bp"]', svg); - const cp = q('[data-vertex="cp"]', svg); - const apLabel = q('[data-vertex-label="ap"]', svg); - const bpLabel = q('[data-vertex-label="bp"]', svg); - const cpLabel = q('[data-vertex-label="cp"]', svg); - const apbp = q('[data-side="apbp"]', svg); - const bpcp = q('[data-side="bpcp"]', svg); - const cpap = q('[data-side="cpap"]', svg); - const tickApBp = q('[data-ticks="apbp"]', svg); - const tickBpCp = q('[data-ticks="bpcp"]', svg); - const tickCpAp = q('[data-ticks="cpap"]', svg); - - const kReadout = q('[data-readout="k"]'); - const kSlider = q('[data-control="k-slider"]'); - const apbpReadout = q('[data-readout-side="apbp"]'); - const bpcpReadout = q('[data-readout-side="bpcp"]'); - const cpapReadout = q('[data-readout-side="cpap"]'); - const ratioAB = q('[data-readout-ratio="ab"]'); - const ratioBC = q('[data-readout-ratio="bc"]'); - const ratioCA = q('[data-readout-ratio="ca"]'); - - if ( - !ap || !bp || !cp || !apLabel || !bpLabel || !cpLabel || - !apbp || !bpcp || !cpap || - !tickApBp || !tickBpCp || !tickCpAp || - !kReadout || !kSlider || - !apbpReadout || !bpcpReadout || !cpapReadout || - !ratioAB || !ratioBC || !ratioCA - ) return null; - - return { - ap, bp, cp, apLabel, bpLabel, cpLabel, - apbp, bpcp, cpap, - tickApBp, tickBpCp, tickCpAp, - kReadout, kSlider, - apbpReadout, bpcpReadout, cpapReadout, - ratioAB, ratioBC, ratioCA, - }; -} - -export function setupSimilarityScale(svgSelector: string) { - const svg = document.querySelector(svgSelector); - if (!svg) return; - const refs = getRefs(svg); - if (!refs) return; - - const ctrl = new AbortController(); - const opts = { signal: ctrl.signal } as AddEventListenerOptions; - - // Static ticks for triangle 1 (ABC) — render once, never change. - const tickAB = svg.querySelector('[data-ticks="ab"]'); - const tickBC = svg.querySelector('[data-ticks="bc"]'); - const tickCA = svg.querySelector('[data-ticks="ca"]'); - if (tickAB && tickBC && tickCA) { - renderTicks(tickAB, A, B, 1, PAIR1); - renderTicks(tickBC, B, C, 2, PAIR2); - renderTicks(tickCA, C, A, 3, PAIR3); - } - - const initialK = parseFloat(refs.kSlider.value) || 1; - update(refs, initialK); - - refs.kSlider.addEventListener( - 'input', - () => { - const k = parseFloat(refs.kSlider.value); - if (Number.isFinite(k)) update(refs, k); - }, - opts, - ); - - document.addEventListener('astro:before-swap', () => ctrl.abort(), { once: true }); -} diff --git a/gstack/src/geom-engine/circle.ts b/gstack/src/geom-engine/circle.js similarity index 52% rename from gstack/src/geom-engine/circle.ts rename to gstack/src/geom-engine/circle.js index c6ae3a1..619a6ca 100644 --- a/gstack/src/geom-engine/circle.ts +++ b/gstack/src/geom-engine/circle.js @@ -1,16 +1,27 @@ -import type { Vec2 } from './vec'; import { add, dot, len, normalize, scale, sub, vec } from './vec'; -export interface Circle { - readonly center: Vec2; - readonly radius: number; -} +/** @typedef {import('./vec').Vec2} Vec2 */ -export function circle(cx: number, cy: number, r: number): Circle { +/** + * @typedef {{ readonly center: Vec2, readonly radius: number }} Circle + */ + +/** + * @param {number} cx + * @param {number} cy + * @param {number} r + * @returns {Circle} + */ +export function circle(cx, cy, r) { return { center: vec(cx, cy), radius: r }; } -export function projectToCircle(point: Vec2, c: Circle): Vec2 { +/** + * @param {Vec2} point + * @param {Circle} c + * @returns {Vec2} + */ +export function projectToCircle(point, c) { const dir = sub(point, c.center); const d = len(dir); if (d === 0) { @@ -20,14 +31,25 @@ export function projectToCircle(point: Vec2, c: Circle): Vec2 { return add(c.center, scale(normalize(dir), c.radius)); } -export function pointOnCircle(c: Circle, angleDeg: number): Vec2 { +/** + * @param {Circle} c + * @param {number} angleDeg + * @returns {Vec2} + */ +export function pointOnCircle(c, angleDeg) { const rad = (angleDeg * Math.PI) / 180; return vec(c.center.x + c.radius * Math.cos(rad), c.center.y + c.radius * Math.sin(rad)); } -export function angleAtVertex(a: Vec2, vertex: Vec2, b: Vec2): number { - // Returns the unsigned angle at `vertex` of triangle (a, vertex, b), in degrees. - // Range: [0, 180]. Returns 0 if `vertex` coincides with `a` or `b`. +/** + * Returns the unsigned angle at `vertex` of triangle (a, vertex, b), in degrees. + * Range: [0, 180]. Returns 0 if `vertex` coincides with `a` or `b`. + * @param {Vec2} a + * @param {Vec2} vertex + * @param {Vec2} b + * @returns {number} + */ +export function angleAtVertex(a, vertex, b) { const va = sub(a, vertex); const vb = sub(b, vertex); const lenA = len(va); diff --git a/gstack/src/geom-engine/circle.test.ts b/gstack/src/geom-engine/circle.test.js similarity index 98% rename from gstack/src/geom-engine/circle.test.ts rename to gstack/src/geom-engine/circle.test.js index c2cec78..923b2c7 100644 --- a/gstack/src/geom-engine/circle.test.ts +++ b/gstack/src/geom-engine/circle.test.js @@ -95,7 +95,7 @@ describe('inscribed-angle invariance (the killer-demo property)', () => { ); it('all sampled M on the major arc give the same inscribed angle (within 0.5°)', () => { - const reference = inscribedAngles[0]!; + const reference = /** @type {number} */ (inscribedAngles[0]); for (const angle of inscribedAngles) { expect(Math.abs(angle - reference)).toBeLessThan(0.5); } diff --git a/gstack/src/geom-engine/triangle.js b/gstack/src/geom-engine/triangle.js new file mode 100644 index 0000000..1905e9c --- /dev/null +++ b/gstack/src/geom-engine/triangle.js @@ -0,0 +1,53 @@ +import { dist, EPSILON_LEN } from './vec'; + +/** @typedef {import('./vec').Vec2} Vec2 */ + +/** + * @typedef {{ readonly a: Vec2, readonly b: Vec2, readonly c: Vec2 }} Triangle + */ + +/** + * @param {Vec2} a + * @param {Vec2} b + * @param {Vec2} c + * @returns {Triangle} + */ +export function triangle(a, b, c) { + return { a, b, c }; +} + +/** + * @typedef {{ readonly ab: number, readonly bc: number, readonly ca: number }} SideLengths + */ + +/** + * @param {Triangle} t + * @returns {SideLengths} + */ +export function sides(t) { + return { + ab: dist(t.a, t.b), + bc: dist(t.b, t.c), + ca: dist(t.c, t.a), + }; +} + +/** + * Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A'). + * SGK pedagogy treats vertex labels as defining the correspondence — a permuted + * match would still be the same shape but a different theorem case. We want the + * strict labeled version so the UI's color/tick pairing has unambiguous meaning. + * @param {Triangle} t1 + * @param {Triangle} t2 + * @param {number} [eps] + * @returns {boolean} + */ +export function congruentSSS(t1, t2, eps = EPSILON_LEN) { + const s1 = sides(t1); + const s2 = sides(t2); + return ( + Math.abs(s1.ab - s2.ab) < eps && + Math.abs(s1.bc - s2.bc) < eps && + Math.abs(s1.ca - s2.ca) < eps + ); +} diff --git a/gstack/src/geom-engine/triangle.test.ts b/gstack/src/geom-engine/triangle.test.js similarity index 100% rename from gstack/src/geom-engine/triangle.test.ts rename to gstack/src/geom-engine/triangle.test.js diff --git a/gstack/src/geom-engine/triangle.ts b/gstack/src/geom-engine/triangle.ts deleted file mode 100644 index a44db42..0000000 --- a/gstack/src/geom-engine/triangle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Vec2 } from './vec'; -import { dist, EPSILON_LEN } from './vec'; - -export interface Triangle { - readonly a: Vec2; - readonly b: Vec2; - readonly c: Vec2; -} - -export function triangle(a: Vec2, b: Vec2, c: Vec2): Triangle { - return { a, b, c }; -} - -export interface SideLengths { - readonly ab: number; - readonly bc: number; - readonly ca: number; -} - -export function sides(t: Triangle): SideLengths { - return { - ab: dist(t.a, t.b), - bc: dist(t.b, t.c), - ca: dist(t.c, t.a), - }; -} - -// Position-strict SSS: corresponding sides must match (AB↔A'B', BC↔B'C', CA↔C'A'). -// SGK pedagogy treats vertex labels as defining the correspondence — a permuted -// match would still be the same shape but a different theorem case. We want the -// strict labeled version so the UI's color/tick pairing has unambiguous meaning. -export function congruentSSS(t1: Triangle, t2: Triangle, eps = EPSILON_LEN): boolean { - const s1 = sides(t1); - const s2 = sides(t2); - return ( - Math.abs(s1.ab - s2.ab) < eps && - Math.abs(s1.bc - s2.bc) < eps && - Math.abs(s1.ca - s2.ca) < eps - ); -} diff --git a/gstack/src/geom-engine/vec.js b/gstack/src/geom-engine/vec.js new file mode 100644 index 0000000..ec057db --- /dev/null +++ b/gstack/src/geom-engine/vec.js @@ -0,0 +1,90 @@ +/** + * @typedef {{ readonly x: number, readonly y: number }} Vec2 + */ + +export const EPSILON_LEN = 0.5; +export const EPSILON_ANGLE_DEG = 0.5; + +/** + * @param {number} x + * @param {number} y + * @returns {Vec2} + */ +export function vec(x, y) { + return { x, y }; +} + +/** + * @param {Vec2} a + * @param {Vec2} b + * @returns {Vec2} + */ +export function add(a, b) { + return { x: a.x + b.x, y: a.y + b.y }; +} + +/** + * @param {Vec2} a + * @param {Vec2} b + * @returns {Vec2} + */ +export function sub(a, b) { + return { x: a.x - b.x, y: a.y - b.y }; +} + +/** + * @param {Vec2} a + * @param {number} k + * @returns {Vec2} + */ +export function scale(a, k) { + // `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates + // with === or Object.is don't see a signed-zero ghost from k=0 paths. + return { x: a.x * k + 0, y: a.y * k + 0 }; +} + +/** + * @param {Vec2} a + * @param {Vec2} b + * @returns {number} + */ +export function dot(a, b) { + return a.x * b.x + a.y * b.y; +} + +/** + * @param {Vec2} a + * @returns {number} + */ +export function len(a) { + return Math.hypot(a.x, a.y); +} + +/** + * @param {Vec2} a + * @param {Vec2} b + * @returns {number} + */ +export function dist(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +/** + * @param {Vec2} a + * @returns {Vec2} + */ +export function normalize(a) { + const l = len(a); + if (l === 0) return { x: 0, y: 0 }; + return { x: a.x / l, y: a.y / l }; +} + +/** + * @param {number} a + * @param {number} b + * @param {number} [eps] + * @returns {boolean} + */ +export function approxEqualLen(a, b, eps = EPSILON_LEN) { + return Math.abs(a - b) < eps; +} diff --git a/gstack/src/geom-engine/vec.test.ts b/gstack/src/geom-engine/vec.test.js similarity index 100% rename from gstack/src/geom-engine/vec.test.ts rename to gstack/src/geom-engine/vec.test.js diff --git a/gstack/src/geom-engine/vec.ts b/gstack/src/geom-engine/vec.ts deleted file mode 100644 index 39ed282..0000000 --- a/gstack/src/geom-engine/vec.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface Vec2 { - readonly x: number; - readonly y: number; -} - -export const EPSILON_LEN = 0.5; -export const EPSILON_ANGLE_DEG = 0.5; - -export function vec(x: number, y: number): Vec2 { - return { x, y }; -} - -export function add(a: Vec2, b: Vec2): Vec2 { - return { x: a.x + b.x, y: a.y + b.y }; -} - -export function sub(a: Vec2, b: Vec2): Vec2 { - return { x: a.x - b.x, y: a.y - b.y }; -} - -export function scale(a: Vec2, k: number): Vec2 { - // `+ 0` normalizes IEEE-754 -0 back to +0 so consumers comparing coordinates - // with === or Object.is don't see a signed-zero ghost from k=0 paths. - return { x: a.x * k + 0, y: a.y * k + 0 }; -} - -export function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - -export function len(a: Vec2): number { - return Math.hypot(a.x, a.y); -} - -export function dist(a: Vec2, b: Vec2): number { - return Math.hypot(a.x - b.x, a.y - b.y); -} - -export function normalize(a: Vec2): Vec2 { - const l = len(a); - if (l === 0) return { x: 0, y: 0 }; - return { x: a.x / l, y: a.y / l }; -} - -export function approxEqualLen(a: number, b: number, eps = EPSILON_LEN): boolean { - return Math.abs(a - b) < eps; -} diff --git a/gstack/src/i18n/index.js b/gstack/src/i18n/index.js new file mode 100644 index 0000000..54740b9 --- /dev/null +++ b/gstack/src/i18n/index.js @@ -0,0 +1,13 @@ +import { vi } from './vi'; + +const locales = /** @type {const} */ ({ vi }); + +/** @typedef {keyof typeof locales} LocaleKey */ + +/** @type {LocaleKey} */ +const defaultLocale = 'vi'; + +/** @returns {typeof vi} */ +export function t() { + return locales[defaultLocale]; +} diff --git a/gstack/src/i18n/index.ts b/gstack/src/i18n/index.ts deleted file mode 100644 index d073b7f..0000000 --- a/gstack/src/i18n/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { vi } from './vi'; - -const locales = { vi } as const; - -export type LocaleKey = keyof typeof locales; - -const defaultLocale: LocaleKey = 'vi'; - -export function t(): typeof vi { - return locales[defaultLocale]; -} diff --git a/gstack/src/i18n/vi.ts b/gstack/src/i18n/vi.js similarity index 98% rename from gstack/src/i18n/vi.ts rename to gstack/src/i18n/vi.js index 1df9c9d..c1fc5b6 100644 --- a/gstack/src/i18n/vi.ts +++ b/gstack/src/i18n/vi.js @@ -1,4 +1,4 @@ -export const vi = { +export const vi = /** @type {const} */ ({ site: { title: 'Hình Học Sống', tagline: 'Học hình bằng cách kéo', @@ -97,6 +97,6 @@ export const vi = { backToHub: '← Về trang chủ', nextTeaser: 'Sắp ra mắt: kéo từng đỉnh tự do (AA / SAS / SSS đồng dạng)', }, -} as const; +}); -export type Locale = typeof vi; +/** @typedef {typeof vi} Locale */ diff --git a/gstack/src/pages/index.astro b/gstack/src/pages/index.astro index 24e456e..f916893 100644 --- a/gstack/src/pages/index.astro +++ b/gstack/src/pages/index.astro @@ -5,9 +5,9 @@ import { t } from '~/i18n'; const copy = t(); const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, ''); const grades = [ - { key: 'lop-7' as const, ...copy.grade['lop-7'] }, - { key: 'lop-8' as const, ...copy.grade['lop-8'] }, - { key: 'lop-9' as const, ...copy.grade['lop-9'] }, + { key: /** @type {const} */ ('lop-7'), ...copy.grade['lop-7'] }, + { key: /** @type {const} */ ('lop-8'), ...copy.grade['lop-8'] }, + { key: /** @type {const} */ ('lop-9'), ...copy.grade['lop-9'] }, ]; --- diff --git a/gstack/vitest.config.ts b/gstack/vitest.config.js similarity index 66% rename from gstack/vitest.config.ts rename to gstack/vitest.config.js index 06c177d..5039d15 100644 --- a/gstack/vitest.config.ts +++ b/gstack/vitest.config.js @@ -2,11 +2,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.js'], coverage: { provider: 'v8', - include: ['src/geom-engine/**/*.ts'], - exclude: ['src/geom-engine/**/*.test.ts'], + include: ['src/geom-engine/**/*.js'], + exclude: ['src/geom-engine/**/*.test.js'], thresholds: { lines: 95, functions: 95, diff --git a/superpowers/index.html b/superpowers/index.html index 3c1c6d7..7815420 100644 --- a/superpowers/index.html +++ b/superpowers/index.html @@ -7,6 +7,6 @@
- + diff --git a/superpowers/jsconfig.json b/superpowers/jsconfig.json new file mode 100644 index 0000000..3c890d7 --- /dev/null +++ b/superpowers/jsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "checkJs": true, + "allowJs": true, + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* jsconfig.json defaults maxNodeModuleJsDepth to 2 (vs. 0 for + tsconfig.json); left at 2 it makes checkJs walk into untyped + node_modules JS packages (e.g. jsdom, pulled in transitively via + vitest's own type declarations) and fail on their internals. Pin it + back to the standard default. */ + "maxNodeModuleJsDepth": 0, + + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "tests", "vite.config.js"] +} diff --git a/superpowers/package.json b/superpowers/package.json index 198d6bf..fb748c4 100644 --- a/superpowers/package.json +++ b/superpowers/package.json @@ -5,9 +5,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -p jsconfig.json --noEmit && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc -p jsconfig.json --noEmit" }, "dependencies": { "phaser": "^3.88.2", diff --git a/superpowers/src/components/App.tsx b/superpowers/src/components/App.jsx similarity index 84% rename from superpowers/src/components/App.tsx rename to superpowers/src/components/App.jsx index 5c8e639..14a1ad3 100644 --- a/superpowers/src/components/App.tsx +++ b/superpowers/src/components/App.jsx @@ -1,5 +1,4 @@ import { useState, useRef, useCallback, useEffect } from "react"; -import { Difficulty } from "../types"; import { GameStateManager } from "../game/state"; import { Menu } from "./Menu"; import { DifficultySelect } from "./DifficultySelect"; @@ -8,15 +7,20 @@ import { HUD } from "./HUD"; import { Toast } from "./Toast"; import { GameOver } from "./GameOver"; -type Screen = "menu" | "difficulty" | "game" | "gameover"; +/** @typedef {import("../types").Difficulty} Difficulty */ +/** @typedef {"menu" | "difficulty" | "game" | "gameover"} Screen */ +/** @returns {import("react").JSX.Element} */ function App() { - const [screen, setScreen] = useState("menu"); - const [difficulty, setDifficulty] = useState("easy"); + const [screen, setScreen] = useState(/** @type {Screen} */ ("menu")); + const [difficulty, setDifficulty] = useState( + /** @type {Difficulty} */ ("easy") + ); const [toastVisible, setToastVisible] = useState(false); const stateManager = useRef(new GameStateManager()).current; - const handleSelectDifficulty = (d: Difficulty) => { + /** @param {Difficulty} d */ + const handleSelectDifficulty = (d) => { setDifficulty(d); stateManager.startGame(d); setScreen("game"); diff --git a/superpowers/src/components/DifficultySelect.tsx b/superpowers/src/components/DifficultySelect.jsx similarity index 77% rename from superpowers/src/components/DifficultySelect.tsx rename to superpowers/src/components/DifficultySelect.jsx index 42f9108..07a8f5a 100644 --- a/superpowers/src/components/DifficultySelect.tsx +++ b/superpowers/src/components/DifficultySelect.jsx @@ -1,17 +1,23 @@ -import { Difficulty } from "../types"; +/** @typedef {import("../types").Difficulty} Difficulty */ -interface DifficultySelectProps { - onSelect: (difficulty: Difficulty) => void; - onBack: () => void; -} +/** + * @typedef {Object} DifficultySelectProps + * @property {(difficulty: Difficulty) => void} onSelect + * @property {() => void} onBack + */ -const difficulties: { key: Difficulty; label: string; desc: string }[] = [ +/** @type {{ key: Difficulty; label: string; desc: string }[]} */ +const difficulties = [ { key: "easy", label: "Easy", desc: "6×4 grid • 5 min • 5 hints" }, { key: "medium", label: "Medium", desc: "8×6 grid • 4 min • 3 hints" }, { key: "hard", label: "Hard", desc: "10×8 grid • 3 min • 1 hint" }, ]; -export function DifficultySelect({ onSelect, onBack }: DifficultySelectProps) { +/** + * @param {DifficultySelectProps} props + * @returns {import("react").JSX.Element} + */ +export function DifficultySelect({ onSelect, onBack }) { return (
void; -} +/** @typedef {import("../types").Difficulty} Difficulty */ -export function GameContainer({ difficulty, stateManager, onGameOver }: GameContainerProps) { - const containerRef = useRef(null); - const gameRef = useRef(null); +/** + * @typedef {Object} GameContainerProps + * @property {Difficulty} difficulty + * @property {GameStateManager} stateManager + * @property {() => void} onGameOver + */ + +/** + * @param {GameContainerProps} props + * @returns {import("react").JSX.Element} + */ +export function GameContainer({ difficulty, stateManager, onGameOver }) { + const containerRef = useRef(/** @type {HTMLDivElement | null} */ (null)); + const gameRef = useRef(/** @type {Phaser.Game | null} */ (null)); useEffect(() => { if (!containerRef.current) return; diff --git a/superpowers/src/components/GameOver.tsx b/superpowers/src/components/GameOver.jsx similarity index 83% rename from superpowers/src/components/GameOver.tsx rename to superpowers/src/components/GameOver.jsx index 9779568..fdad521 100644 --- a/superpowers/src/components/GameOver.tsx +++ b/superpowers/src/components/GameOver.jsx @@ -1,15 +1,21 @@ -import { Difficulty } from "../types"; import { GameStateManager } from "../game/state"; import { DIFFICULTY_CONFIGS } from "../game/constants"; -interface GameOverProps { - stateManager: GameStateManager; - difficulty: Difficulty; - onPlayAgain: () => void; - onMenu: () => void; -} +/** @typedef {import("../types").Difficulty} Difficulty */ -export function GameOver({ stateManager, difficulty, onPlayAgain, onMenu }: GameOverProps) { +/** + * @typedef {Object} GameOverProps + * @property {GameStateManager} stateManager + * @property {Difficulty} difficulty + * @property {() => void} onPlayAgain + * @property {() => void} onMenu + */ + +/** + * @param {GameOverProps} props + * @returns {import("react").JSX.Element} + */ +export function GameOver({ stateManager, difficulty, onPlayAgain, onMenu }) { const state = stateManager.getState(); const config = DIFFICULTY_CONFIGS[difficulty]; const timeUsed = config.timerSeconds - state.timerSeconds; diff --git a/superpowers/src/components/HUD.tsx b/superpowers/src/components/HUD.jsx similarity index 90% rename from superpowers/src/components/HUD.tsx rename to superpowers/src/components/HUD.jsx index 5bdb565..eaf9344 100644 --- a/superpowers/src/components/HUD.tsx +++ b/superpowers/src/components/HUD.jsx @@ -1,12 +1,17 @@ import { useEffect, useState } from "react"; import { GameStateManager } from "../game/state"; -interface HUDProps { - stateManager: GameStateManager; - onPause: () => void; -} +/** + * @typedef {Object} HUDProps + * @property {GameStateManager} stateManager + * @property {() => void} onPause + */ -export function HUD({ stateManager, onPause }: HUDProps) { +/** + * @param {HUDProps} props + * @returns {import("react").JSX.Element} + */ +export function HUD({ stateManager, onPause }) { const [state, setState] = useState(stateManager.getState()); useEffect(() => { diff --git a/superpowers/src/components/Menu.tsx b/superpowers/src/components/Menu.jsx similarity index 78% rename from superpowers/src/components/Menu.tsx rename to superpowers/src/components/Menu.jsx index 5a3a4aa..6b5c5ec 100644 --- a/superpowers/src/components/Menu.tsx +++ b/superpowers/src/components/Menu.jsx @@ -1,8 +1,13 @@ -interface MenuProps { - onPlay: () => void; -} +/** + * @typedef {Object} MenuProps + * @property {() => void} onPlay + */ -export function Menu({ onPlay }: MenuProps) { +/** + * @param {MenuProps} props + * @returns {import("react").JSX.Element} + */ +export function Menu({ onPlay }) { return (
void; -} +/** + * @typedef {Object} ToastProps + * @property {string} message + * @property {boolean} visible + * @property {() => void} onHide + */ -export function Toast({ message, visible, onHide }: ToastProps) { +/** + * @param {ToastProps} props + * @returns {import("react").JSX.Element | null} + */ +export function Toast({ message, visible, onHide }) { const [show, setShow] = useState(false); useEffect(() => { diff --git a/superpowers/src/game/board.ts b/superpowers/src/game/board.js similarity index 68% rename from superpowers/src/game/board.ts rename to superpowers/src/game/board.js index a59ddc6..be3c0cd 100644 --- a/superpowers/src/game/board.ts +++ b/superpowers/src/game/board.js @@ -1,17 +1,28 @@ -import { Board, Difficulty, TileData, Point } from "../types"; import { DIFFICULTY_CONFIGS } from "./constants"; import { getEmojisForDifficulty } from "./emoji"; import { hasAnyValidMove } from "./pathfinder"; +/** + * @typedef {import("../types").Board} Board + * @typedef {import("../types").Difficulty} Difficulty + * @typedef {import("../types").TileData} TileData + * @typedef {import("../types").Point} Point + */ + let nextTileId = 0; -export function createBoard(difficulty: Difficulty): Board { +/** + * @param {Difficulty} difficulty + * @returns {Board} + */ +export function createBoard(difficulty) { const config = DIFFICULTY_CONFIGS[difficulty]; const { rows, cols } = config; const emojis = getEmojisForDifficulty(difficulty); // Create pairs - const tiles: TileData[] = []; + /** @type {TileData[]} */ + const tiles = []; nextTileId = 0; for (const emoji of emojis) { tiles.push({ emoji, id: nextTileId++ }); @@ -25,10 +36,12 @@ export function createBoard(difficulty: Difficulty): Board { } // Place into grid - const board: Board = []; + /** @type {Board} */ + const board = []; let idx = 0; for (let r = 0; r < rows; r++) { - const row: (TileData | null)[] = []; + /** @type {(TileData | null)[]} */ + const row = []; for (let c = 0; c < cols; c++) { row.push(tiles[idx++]); } @@ -37,7 +50,7 @@ export function createBoard(difficulty: Difficulty): Board { // Validate at least one valid move exists; reshuffle if not while (!hasAnyValidMove(board)) { - const allTiles = board.flat().filter((t): t is TileData => t !== null); + const allTiles = board.flat().filter((t) => t !== null); for (let i = allTiles.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [allTiles[i], allTiles[j]] = [allTiles[j], allTiles[i]]; @@ -53,8 +66,13 @@ export function createBoard(difficulty: Difficulty): Board { return board; } -export function getRemainingTiles(board: Board): { tile: TileData; pos: Point }[] { - const result: { tile: TileData; pos: Point }[] = []; +/** + * @param {Board} board + * @returns {{ tile: TileData; pos: Point }[]} + */ +export function getRemainingTiles(board) { + /** @type {{ tile: TileData; pos: Point }[]} */ + const result = []; for (let r = 0; r < board.length; r++) { for (let c = 0; c < board[r].length; c++) { const cell = board[r][c]; @@ -66,18 +84,25 @@ export function getRemainingTiles(board: Board): { tile: TileData; pos: Point }[ return result; } -export function shuffleBoard(board: Board): Board { +/** + * @param {Board} board + * @returns {Board} + */ +export function shuffleBoard(board) { const rows = board.length; const cols = board[0].length; - const tiles: TileData[] = []; - const occupiedPositions: Point[] = []; - const emptyPositions: Point[] = []; + /** @type {TileData[]} */ + const tiles = []; + /** @type {Point[]} */ + const occupiedPositions = []; + /** @type {Point[]} */ + const emptyPositions = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (board[r][c] !== null) { - tiles.push(board[r][c]!); + tiles.push(/** @type {TileData} */ (board[r][c])); occupiedPositions.push({ row: r, col: c }); } else { emptyPositions.push({ row: r, col: c }); @@ -92,7 +117,8 @@ export function shuffleBoard(board: Board): Board { } // Create new board with nulls - const newBoard: Board = []; + /** @type {Board} */ + const newBoard = []; for (let r = 0; r < rows; r++) { newBoard.push(new Array(cols).fill(null)); } diff --git a/superpowers/src/game/constants.ts b/superpowers/src/game/constants.js similarity index 68% rename from superpowers/src/game/constants.ts rename to superpowers/src/game/constants.js index 3dea814..8890b80 100644 --- a/superpowers/src/game/constants.ts +++ b/superpowers/src/game/constants.js @@ -1,6 +1,10 @@ -import { Difficulty, DifficultyConfig } from "../types"; +/** + * @typedef {import("../types").Difficulty} Difficulty + * @typedef {import("../types").DifficultyConfig} DifficultyConfig + */ -export const DIFFICULTY_CONFIGS: Record = { +/** @type {Record} */ +export const DIFFICULTY_CONFIGS = { easy: { rows: 4, cols: 6, diff --git a/superpowers/src/game/emoji.ts b/superpowers/src/game/emoji.js similarity index 75% rename from superpowers/src/game/emoji.ts rename to superpowers/src/game/emoji.js index 007b84f..6cd2f6d 100644 --- a/superpowers/src/game/emoji.ts +++ b/superpowers/src/game/emoji.js @@ -1,7 +1,9 @@ -import { Difficulty } from "../types"; import { DIFFICULTY_CONFIGS } from "./constants"; -export const EMOJI_POOL: string[] = [ +/** @typedef {import("../types").Difficulty} Difficulty */ + +/** @type {string[]} */ +export const EMOJI_POOL = [ "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", @@ -10,7 +12,11 @@ export const EMOJI_POOL: string[] = [ "🐟", "🐬", "🐳", "🐊", ]; -export function getEmojisForDifficulty(difficulty: Difficulty): string[] { +/** + * @param {Difficulty} difficulty + * @returns {string[]} + */ +export function getEmojisForDifficulty(difficulty) { const count = DIFFICULTY_CONFIGS[difficulty].pairCount; const shuffled = [...EMOJI_POOL]; for (let i = shuffled.length - 1; i > 0; i--) { diff --git a/superpowers/src/game/pathfinder.ts b/superpowers/src/game/pathfinder.js similarity index 65% rename from superpowers/src/game/pathfinder.ts rename to superpowers/src/game/pathfinder.js index 25362da..f1af5e7 100644 --- a/superpowers/src/game/pathfinder.ts +++ b/superpowers/src/game/pathfinder.js @@ -1,6 +1,16 @@ -import { Board, Point } from "../types"; +/** + * @typedef {import("../types").Board} Board + * @typedef {import("../types").Point} Point + * @typedef {import("../types").TileData} TileData + */ -function isEmpty(board: Board, row: number, col: number): boolean { +/** + * @param {Board} board + * @param {number} row + * @param {number} col + * @returns {boolean} + */ +function isEmpty(board, row, col) { const rows = board.length; const cols = board[0].length; if (row < 0 || row >= rows || col < 0 || col >= cols) { @@ -9,21 +19,25 @@ function isEmpty(board: Board, row: number, col: number): boolean { return board[row][col] === null; } -const DIRS: [number, number][] = [ +/** @type {[number, number][]} */ +const DIRS = [ [0, 1], [0, -1], [1, 0], [-1, 0], ]; -function raycast( - board: Board, - row: number, - col: number, - dr: number, - dc: number -): Point[] { - const points: Point[] = []; +/** + * @param {Board} board + * @param {number} row + * @param {number} col + * @param {number} dr + * @param {number} dc + * @returns {Point[]} + */ +function raycast(board, row, col, dr, dc) { + /** @type {Point[]} */ + const points = []; let r = row + dr; let c = col + dc; const rows = board.length; @@ -36,11 +50,13 @@ function raycast( return points; } -function canConnectStraight( - board: Board, - a: Point, - b: Point -): boolean { +/** + * @param {Board} board + * @param {Point} a + * @param {Point} b + * @returns {boolean} + */ +function canConnectStraight(board, a, b) { if (a.row === b.row) { const minC = Math.min(a.col, b.col); const maxC = Math.max(a.col, b.col); @@ -60,11 +76,13 @@ function canConnectStraight( return false; } -export function findPath( - board: Board, - a: Point, - b: Point -): Point[] | null { +/** + * @param {Board} board + * @param {Point} a + * @param {Point} b + * @returns {Point[] | null} + */ +export function findPath(board, a, b) { const tileA = board[a.row]?.[a.col]; const tileB = board[b.row]?.[b.col]; if (!tileA || !tileB || tileA.emoji !== tileB.emoji) return null; @@ -76,7 +94,8 @@ export function findPath( } // 1 bend: check two possible corners - const corner1: Point = { row: a.row, col: b.col }; + /** @type {Point} */ + const corner1 = { row: a.row, col: b.col }; if ( isEmpty(board, corner1.row, corner1.col) && canConnectStraight(board, a, corner1) && @@ -85,7 +104,8 @@ export function findPath( return [a, corner1, b]; } - const corner2: Point = { row: b.row, col: a.col }; + /** @type {Point} */ + const corner2 = { row: b.row, col: a.col }; if ( isEmpty(board, corner2.row, corner2.col) && canConnectStraight(board, a, corner2) && @@ -98,7 +118,8 @@ export function findPath( for (const [dr, dc] of DIRS) { const reachable = raycast(board, a.row, a.col, dr, dc); for (const mid of reachable) { - const cornerA: Point = { row: mid.row, col: b.col }; + /** @type {Point} */ + const cornerA = { row: mid.row, col: b.col }; if ( isEmpty(board, cornerA.row, cornerA.col) && canConnectStraight(board, mid, cornerA) && @@ -107,7 +128,8 @@ export function findPath( return [a, mid, cornerA, b]; } - const cornerB: Point = { row: b.row, col: mid.col }; + /** @type {Point} */ + const cornerB = { row: b.row, col: mid.col }; if ( isEmpty(board, cornerB.row, cornerB.col) && canConnectStraight(board, mid, cornerB) && @@ -125,20 +147,29 @@ export function findPath( return null; } -export function hasAnyValidMove(board: Board): boolean { - const tiles: { pos: Point; emoji: string }[] = []; +/** + * @param {Board} board + * @returns {boolean} + */ +export function hasAnyValidMove(board) { + /** @type {{ pos: Point; emoji: string }[]} */ + const tiles = []; for (let r = 0; r < board.length; r++) { for (let c = 0; c < board[r].length; c++) { if (board[r][c] !== null) { - tiles.push({ pos: { row: r, col: c }, emoji: board[r][c]!.emoji }); + tiles.push({ + pos: { row: r, col: c }, + emoji: /** @type {TileData} */ (board[r][c]).emoji, + }); } } } - const groups = new Map(); + /** @type {Map} */ + const groups = new Map(); for (const t of tiles) { if (!groups.has(t.emoji)) groups.set(t.emoji, []); - groups.get(t.emoji)!.push(t.pos); + /** @type {Point[]} */ (groups.get(t.emoji)).push(t.pos); } for (const [, positions] of groups) { diff --git a/superpowers/src/game/scoring.ts b/superpowers/src/game/scoring.js similarity index 68% rename from superpowers/src/game/scoring.ts rename to superpowers/src/game/scoring.js index 313b4b5..acfeb88 100644 --- a/superpowers/src/game/scoring.ts +++ b/superpowers/src/game/scoring.js @@ -1,9 +1,11 @@ import { BASE_MATCH_SCORE, SPEED_BONUS_MAX, SPEED_BONUS_WINDOW_MS } from "./constants"; -export function calculateMatchScore( - msSinceLastMatch: number, - combo: number -): number { +/** + * @param {number} msSinceLastMatch + * @param {number} combo + * @returns {number} + */ +export function calculateMatchScore(msSinceLastMatch, combo) { let speedBonus = 0; if (msSinceLastMatch < SPEED_BONUS_WINDOW_MS) { const ratio = 1 - msSinceLastMatch / SPEED_BONUS_WINDOW_MS; diff --git a/superpowers/src/game/state.ts b/superpowers/src/game/state.js similarity index 54% rename from superpowers/src/game/state.ts rename to superpowers/src/game/state.js index 86b6742..2a7f4a0 100644 --- a/superpowers/src/game/state.ts +++ b/superpowers/src/game/state.js @@ -1,24 +1,27 @@ -import { Difficulty, GameStatus } from "../types"; import { DIFFICULTY_CONFIGS } from "./constants"; -type Listener = () => void; +/** + * @typedef {import("../types").Difficulty} Difficulty + * @typedef {import("../types").GameStatus} GameStatus + */ -interface GameState { - status: GameStatus; - difficulty: Difficulty | null; - score: number; - timerSeconds: number; - hintsRemaining: number; - shufflesRemaining: number; - combo: number; - lastMatchTime: number; -} +/** @typedef {() => void} Listener */ + +/** + * @typedef {Object} GameState + * @property {GameStatus} status + * @property {Difficulty | null} difficulty + * @property {number} score + * @property {number} timerSeconds + * @property {number} hintsRemaining + * @property {number} shufflesRemaining + * @property {number} combo + * @property {number} lastMatchTime + */ export class GameStateManager { - private state: GameState; - private listeners: Map = new Map(); - constructor() { + /** @type {GameState} */ this.state = { status: "menu", difficulty: null, @@ -29,20 +32,33 @@ export class GameStateManager { combo: 1, lastMatchTime: 0, }; + /** @type {Map} */ + this.listeners = new Map(); } - getState(): Readonly { + /** @returns {Readonly} */ + getState() { return { ...this.state }; } - on(event: string, listener: Listener): void { + /** + * @param {string} event + * @param {Listener} listener + * @returns {void} + */ + on(event, listener) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } - this.listeners.get(event)!.push(listener); + /** @type {Listener[]} */ (this.listeners.get(event)).push(listener); } - off(event: string, listener: Listener): void { + /** + * @param {string} event + * @param {Listener} listener + * @returns {void} + */ + off(event, listener) { const listeners = this.listeners.get(event); if (listeners) { const idx = listeners.indexOf(listener); @@ -50,14 +66,22 @@ export class GameStateManager { } } - emit(event: string): void { + /** + * @param {string} event + * @returns {void} + */ + emit(event) { const listeners = this.listeners.get(event); if (listeners) { for (const l of listeners) l(); } } - startGame(difficulty: Difficulty): void { + /** + * @param {Difficulty} difficulty + * @returns {void} + */ + startGame(difficulty) { const config = DIFFICULTY_CONFIGS[difficulty]; this.state = { status: "playing", @@ -72,26 +96,33 @@ export class GameStateManager { this.emit("stateChange"); } - addScore(points: number): void { + /** + * @param {number} points + * @returns {void} + */ + addScore(points) { this.state.score += points; this.emit("stateChange"); } - useHint(): boolean { + /** @returns {boolean} */ + useHint() { if (this.state.hintsRemaining <= 0) return false; this.state.hintsRemaining--; this.emit("stateChange"); return true; } - useShuffle(): boolean { + /** @returns {boolean} */ + useShuffle() { if (this.state.shufflesRemaining <= 0) return false; this.state.shufflesRemaining--; this.emit("stateChange"); return true; } - tick(): void { + /** @returns {void} */ + tick() { if (this.state.status !== "playing") return; this.state.timerSeconds--; if (this.state.timerSeconds <= 0) { @@ -101,22 +132,32 @@ export class GameStateManager { this.emit("stateChange"); } - incrementCombo(): void { + /** @returns {void} */ + incrementCombo() { this.state.combo++; this.emit("stateChange"); } - resetCombo(): void { + /** @returns {void} */ + resetCombo() { this.state.combo = 1; this.emit("stateChange"); } - setStatus(status: GameStatus): void { + /** + * @param {GameStatus} status + * @returns {void} + */ + setStatus(status) { this.state.status = status; this.emit("stateChange"); } - setLastMatchTime(time: number): void { + /** + * @param {number} time + * @returns {void} + */ + setLastMatchTime(time) { this.state.lastMatchTime = time; } } diff --git a/superpowers/src/main.tsx b/superpowers/src/main.jsx similarity index 69% rename from superpowers/src/main.tsx rename to superpowers/src/main.jsx index 05c04dc..6bdcbe0 100644 --- a/superpowers/src/main.tsx +++ b/superpowers/src/main.jsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./components/App"; -createRoot(document.getElementById("root")!).render( +createRoot(/** @type {HTMLElement} */ (document.getElementById("root"))).render( diff --git a/superpowers/src/phaser/config.ts b/superpowers/src/phaser/config.js similarity index 65% rename from superpowers/src/phaser/config.ts rename to superpowers/src/phaser/config.js index 92cc920..1c53ff0 100644 --- a/superpowers/src/phaser/config.ts +++ b/superpowers/src/phaser/config.js @@ -2,11 +2,13 @@ import Phaser from "phaser"; import { PreloadScene } from "./scenes/PreloadScene"; import { GameScene } from "./scenes/GameScene"; -export function createPhaserConfig( - parent: HTMLElement, - width: number, - height: number -): Phaser.Types.Core.GameConfig { +/** + * @param {HTMLElement} parent + * @param {number} width + * @param {number} height + * @returns {Phaser.Types.Core.GameConfig} + */ +export function createPhaserConfig(parent, width, height) { return { type: Phaser.AUTO, parent, diff --git a/superpowers/src/phaser/scenes/GameScene.ts b/superpowers/src/phaser/scenes/GameScene.js similarity index 77% rename from superpowers/src/phaser/scenes/GameScene.ts rename to superpowers/src/phaser/scenes/GameScene.js index 5271173..0e8fc65 100644 --- a/superpowers/src/phaser/scenes/GameScene.ts +++ b/superpowers/src/phaser/scenes/GameScene.js @@ -1,35 +1,52 @@ import Phaser from "phaser"; -import { Board, Difficulty, Point } from "../../types"; -import { GameStateManager } from "../../game/state"; import { createBoard, shuffleBoard, getRemainingTiles } from "../../game/board"; import { findPath, hasAnyValidMove } from "../../game/pathfinder"; import { calculateMatchScore } from "../../game/scoring"; import { DIFFICULTY_CONFIGS } from "../../game/constants"; +/** + * @typedef {import("../../types").Board} Board + * @typedef {import("../../types").Difficulty} Difficulty + * @typedef {import("../../types").Point} Point + */ +/** @typedef {import("../../game/state").GameStateManager} GameStateManager */ + const TILE_SIZE = 56; const TILE_GAP = 4; export class GameScene extends Phaser.Scene { - private board!: Board; - private difficulty!: Difficulty; - private stateManager!: GameStateManager; - private tileObjects: Map = new Map(); - private selectedTile: Point | null = null; - private selectedHighlight: Phaser.GameObjects.Rectangle | null = null; - private lineGraphics!: Phaser.GameObjects.Graphics; - private isProcessing = false; - private timerEvent: Phaser.Time.TimerEvent | null = null; - private hintHandler!: () => void; - private shuffleHandler!: () => void; - private solveInterval: number | null = null; - constructor() { super({ key: "GameScene" }); + + /** @type {Board} */ + this.board = []; + /** @type {Difficulty} */ + this.difficulty = "easy"; + /** @type {GameStateManager} */ + this.stateManager = /** @type {GameStateManager} */ (/** @type {unknown} */ (null)); + /** @type {Map} */ + this.tileObjects = new Map(); + /** @type {Point | null} */ + this.selectedTile = null; + /** @type {Phaser.GameObjects.Rectangle | null} */ + this.selectedHighlight = null; + /** @type {Phaser.GameObjects.Graphics} */ + this.lineGraphics = /** @type {Phaser.GameObjects.Graphics} */ (/** @type {unknown} */ (null)); + this.isProcessing = false; + /** @type {Phaser.Time.TimerEvent | null} */ + this.timerEvent = null; + /** @type {() => void} */ + this.hintHandler = () => {}; + /** @type {() => void} */ + this.shuffleHandler = () => {}; + /** @type {number | null} */ + this.solveInterval = null; } - create(): void { - this.difficulty = this.registry.get("difficulty") as Difficulty; - this.stateManager = this.registry.get("stateManager") as GameStateManager; + /** @returns {void} */ + create() { + this.difficulty = /** @type {Difficulty} */ (this.registry.get("difficulty")); + this.stateManager = /** @type {GameStateManager} */ (this.registry.get("stateManager")); this.board = createBoard(this.difficulty); this.lineGraphics = this.add.graphics(); @@ -54,10 +71,12 @@ export class GameScene extends Phaser.Scene { }); // Expose solve() on window for console use - (window as unknown as Record).solve = () => this.startSolve(); + /** @type {Record} */ (/** @type {unknown} */ (window)).solve = () => + this.startSolve(); } - private renderBoard(): void { + /** @returns {void} */ + renderBoard() { for (const [, obj] of this.tileObjects) { obj.destroy(); } @@ -111,7 +130,11 @@ export class GameScene extends Phaser.Scene { } } - private getTileScreenPos(point: Point): { x: number; y: number } { + /** + * @param {Point} point + * @returns {{ x: number; y: number }} + */ + getTileScreenPos(point) { const config = DIFFICULTY_CONFIGS[this.difficulty]; const totalWidth = config.cols * (TILE_SIZE + TILE_GAP) - TILE_GAP; const totalHeight = config.rows * (TILE_SIZE + TILE_GAP) - TILE_GAP; @@ -123,7 +146,11 @@ export class GameScene extends Phaser.Scene { }; } - private onTileClick(pos: Point): void { + /** + * @param {Point} pos + * @returns {void} + */ + onTileClick(pos) { if (this.stateManager.getState().status !== "playing") return; const tile = this.board[pos.row][pos.col]; if (!tile) return; @@ -151,7 +178,11 @@ export class GameScene extends Phaser.Scene { } } - private highlightTile(pos: Point): void { + /** + * @param {Point} pos + * @returns {void} + */ + highlightTile(pos) { this.clearHighlight(); const screenPos = this.getTileScreenPos(pos); this.selectedHighlight = this.add.rectangle( @@ -164,19 +195,27 @@ export class GameScene extends Phaser.Scene { this.selectedHighlight.setFillStyle(0xe94560, 0.15); } - private clearHighlight(): void { + /** @returns {void} */ + clearHighlight() { if (this.selectedHighlight) { this.selectedHighlight.destroy(); this.selectedHighlight = null; } } - private clearSelection(): void { + /** @returns {void} */ + clearSelection() { this.selectedTile = null; this.clearHighlight(); } - private handleMatch(a: Point, b: Point, path: Point[]): void { + /** + * @param {Point} a + * @param {Point} b + * @param {Point[]} path + * @returns {void} + */ + handleMatch(a, b, path) { this.drawPath(path); const state = this.stateManager.getState(); @@ -206,7 +245,11 @@ export class GameScene extends Phaser.Scene { }); } - private handleMismatch(clickedPos: Point): void { + /** + * @param {Point} clickedPos + * @returns {void} + */ + handleMismatch(clickedPos) { this.stateManager.resetCombo(); const key = `${clickedPos.row},${clickedPos.col}`; @@ -226,7 +269,11 @@ export class GameScene extends Phaser.Scene { this.highlightTile(clickedPos); } - private removeTile(pos: Point): void { + /** + * @param {Point} pos + * @returns {void} + */ + removeTile(pos) { this.board[pos.row][pos.col] = null; const key = `${pos.row},${pos.col}`; const container = this.tileObjects.get(key); @@ -242,7 +289,11 @@ export class GameScene extends Phaser.Scene { } } - private drawPath(path: Point[]): void { + /** + * @param {Point[]} path + * @returns {void} + */ + drawPath(path) { this.lineGraphics.clear(); this.lineGraphics.lineStyle(4, 0xe94560, 0.8); this.lineGraphics.beginPath(); @@ -259,7 +310,8 @@ export class GameScene extends Phaser.Scene { this.lineGraphics.strokePath(); } - private startTimer(): void { + /** @returns {void} */ + startTimer() { this.timerEvent = this.time.addEvent({ delay: 1000, callback: () => { @@ -272,7 +324,8 @@ export class GameScene extends Phaser.Scene { }); } - handleHint(): void { + /** @returns {void} */ + handleHint() { if (!this.stateManager.useHint()) return; const remaining = getRemainingTiles(this.board); @@ -294,7 +347,11 @@ export class GameScene extends Phaser.Scene { } } - private pulseHint(pos: Point): void { + /** + * @param {Point} pos + * @returns {void} + */ + pulseHint(pos) { const key = `${pos.row},${pos.col}`; const container = this.tileObjects.get(key); if (container) { @@ -309,17 +366,20 @@ export class GameScene extends Phaser.Scene { } } - handleShuffle(): void { + /** @returns {void} */ + handleShuffle() { if (!this.stateManager.useShuffle()) return; this.performShuffle(); } - private autoShuffle(): void { + /** @returns {void} */ + autoShuffle() { this.performShuffle(); this.stateManager.emit("autoShuffle"); } - private performShuffle(): void { + /** @returns {void} */ + performShuffle() { this.clearSelection(); do { this.board = shuffleBoard(this.board); @@ -327,7 +387,8 @@ export class GameScene extends Phaser.Scene { this.renderBoard(); } - private startSolve(): void { + /** @returns {void} */ + startSolve() { if (this.solveInterval !== null) { this.stopSolve(); console.log("Auto-solve stopped."); @@ -346,14 +407,16 @@ export class GameScene extends Phaser.Scene { }, 1000); } - private stopSolve(): void { + /** @returns {void} */ + stopSolve() { if (this.solveInterval !== null) { clearInterval(this.solveInterval); this.solveInterval = null; } } - private solveOneMove(): void { + /** @returns {void} */ + solveOneMove() { if (this.isProcessing) return; const remaining = getRemainingTiles(this.board); for (let i = 0; i < remaining.length; i++) { @@ -377,7 +440,8 @@ export class GameScene extends Phaser.Scene { } } - shutdown(): void { + /** @returns {void} */ + shutdown() { if (this.timerEvent) { this.timerEvent.destroy(); this.timerEvent = null; diff --git a/superpowers/src/phaser/scenes/PreloadScene.ts b/superpowers/src/phaser/scenes/PreloadScene.js similarity index 75% rename from superpowers/src/phaser/scenes/PreloadScene.ts rename to superpowers/src/phaser/scenes/PreloadScene.js index a8f4bd7..9a5e9fb 100644 --- a/superpowers/src/phaser/scenes/PreloadScene.ts +++ b/superpowers/src/phaser/scenes/PreloadScene.js @@ -5,11 +5,13 @@ export class PreloadScene extends Phaser.Scene { super({ key: "PreloadScene" }); } - preload(): void { + /** @returns {void} */ + preload() { // No assets to load — emoji are rendered as text } - create(): void { + /** @returns {void} */ + create() { this.scene.start("GameScene"); } } diff --git a/superpowers/src/types/index.ts b/superpowers/src/types/index.d.ts similarity index 100% rename from superpowers/src/types/index.ts rename to superpowers/src/types/index.d.ts diff --git a/superpowers/tests/game/board.test.ts b/superpowers/tests/game/board.test.js similarity index 94% rename from superpowers/tests/game/board.test.ts rename to superpowers/tests/game/board.test.js index 8191c4f..7cfb323 100644 --- a/superpowers/tests/game/board.test.ts +++ b/superpowers/tests/game/board.test.js @@ -22,7 +22,8 @@ describe("createBoard", () => { it("every tile has a matching pair", () => { const board = createBoard("easy"); - const emojiCounts = new Map(); + /** @type {Map} */ + const emojiCounts = new Map(); for (const row of board) { for (const cell of row) { if (cell) { @@ -37,7 +38,8 @@ describe("createBoard", () => { it("all tiles have unique ids", () => { const board = createBoard("medium"); - const ids = new Set(); + /** @type {Set} */ + const ids = new Set(); for (const row of board) { for (const cell of row) { if (cell) { diff --git a/superpowers/tests/game/emoji.test.ts b/superpowers/tests/game/emoji.test.js similarity index 100% rename from superpowers/tests/game/emoji.test.ts rename to superpowers/tests/game/emoji.test.js diff --git a/superpowers/tests/game/pathfinder.test.ts b/superpowers/tests/game/pathfinder.test.js similarity index 84% rename from superpowers/tests/game/pathfinder.test.ts rename to superpowers/tests/game/pathfinder.test.js index 1720a89..606ee88 100644 --- a/superpowers/tests/game/pathfinder.test.ts +++ b/superpowers/tests/game/pathfinder.test.js @@ -1,8 +1,13 @@ import { describe, it, expect } from "vitest"; import { findPath, hasAnyValidMove } from "../../src/game/pathfinder"; -import { Board } from "../../src/types"; -function makeBoard(grid: (string | null)[][]): Board { +/** @typedef {import("../../src/types").Board} Board */ + +/** + * @param {(string | null)[][]} grid + * @returns {Board} + */ +function makeBoard(grid) { let id = 0; return grid.map((row) => row.map((cell) => (cell !== null ? { emoji: cell, id: id++ } : null)) @@ -16,7 +21,7 @@ describe("findPath", () => { ]); const path = findPath(board, { row: 0, col: 0 }, { row: 0, col: 2 }); expect(path).not.toBeNull(); - expect(path!.length).toBe(2); + expect(/** @type {NonNullable} */ (path).length).toBe(2); }); it("finds direct vertical connection", () => { @@ -27,7 +32,7 @@ describe("findPath", () => { ]); const path = findPath(board, { row: 0, col: 0 }, { row: 2, col: 0 }); expect(path).not.toBeNull(); - expect(path!.length).toBe(2); + expect(/** @type {NonNullable} */ (path).length).toBe(2); }); it("finds one-bend connection", () => { @@ -37,7 +42,7 @@ describe("findPath", () => { ]); const path = findPath(board, { row: 0, col: 0 }, { row: 1, col: 1 }); expect(path).not.toBeNull(); - expect(path!.length).toBe(3); + expect(/** @type {NonNullable} */ (path).length).toBe(3); }); it("finds two-bend connection", () => { @@ -48,7 +53,7 @@ describe("findPath", () => { ]); const path = findPath(board, { row: 0, col: 0 }, { row: 2, col: 2 }); expect(path).not.toBeNull(); - expect(path!.length).toBe(4); + expect(/** @type {NonNullable} */ (path).length).toBe(4); }); it("returns null when no valid path exists", () => { @@ -83,7 +88,7 @@ describe("findPath", () => { ]); const path = findPath(board, { row: 0, col: 0 }, { row: 0, col: 1 }); expect(path).not.toBeNull(); - expect(path!.length).toBe(2); + expect(/** @type {NonNullable} */ (path).length).toBe(2); }); }); diff --git a/superpowers/tests/game/scoring.test.ts b/superpowers/tests/game/scoring.test.js similarity index 100% rename from superpowers/tests/game/scoring.test.ts rename to superpowers/tests/game/scoring.test.js diff --git a/superpowers/tests/game/state.test.ts b/superpowers/tests/game/state.test.js similarity index 98% rename from superpowers/tests/game/state.test.ts rename to superpowers/tests/game/state.test.js index a5796f9..c2c168c 100644 --- a/superpowers/tests/game/state.test.ts +++ b/superpowers/tests/game/state.test.js @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { GameStateManager } from "../../src/game/state"; describe("GameStateManager", () => { - let state: GameStateManager; + /** @type {GameStateManager} */ + let state; beforeEach(() => { state = new GameStateManager(); diff --git a/superpowers/tsconfig.app.json b/superpowers/tsconfig.app.json deleted file mode 100644 index 358ca9b..0000000 --- a/superpowers/tsconfig.app.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/superpowers/tsconfig.json b/superpowers/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/superpowers/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/superpowers/tsconfig.node.json b/superpowers/tsconfig.node.json deleted file mode 100644 index db0becc..0000000 --- a/superpowers/tsconfig.node.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2022", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/superpowers/vite.config.ts b/superpowers/vite.config.js similarity index 100% rename from superpowers/vite.config.ts rename to superpowers/vite.config.js