mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-09-02 18:18:12 +00:00
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.
This commit is contained in:
@@ -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/) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+16
-17
@@ -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 (
|
||||
<div style={{
|
||||
background: 'var(--card)',
|
||||
@@ -79,12 +78,12 @@ export default function QuizQuestion({ question, options, current, total, onSele
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(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}
|
||||
+14
-14
@@ -1,13 +1,9 @@
|
||||
type PersonalityKey = 'zen' | 'nightOwl' | 'socialButterfly';
|
||||
/** @typedef {'zen' | 'nightOwl' | 'socialButterfly'} PersonalityKey */
|
||||
|
||||
type Scores = Record<PersonalityKey, number>;
|
||||
/** @typedef {Record<PersonalityKey, number>} Scores */
|
||||
|
||||
type Props = {
|
||||
scores: Scores;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
const personalities: Record<PersonalityKey, { name: string; coffee: string; tagline: string }> = {
|
||||
/** @type {Record<PersonalityKey, { name: string, coffee: string, tagline: string }>} */
|
||||
const personalities = {
|
||||
zen: {
|
||||
name: 'Zen Minimalist',
|
||||
coffee: 'Black Coffee, Single Origin',
|
||||
@@ -25,10 +21,14 @@ const personalities: Record<PersonalityKey, { name: string; coffee: string; tagl
|
||||
},
|
||||
};
|
||||
|
||||
export default function QuizResults({ scores, onReset }: Props) {
|
||||
/**
|
||||
* @param {{ scores: Scores, onReset: () => 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
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
@@ -4,60 +4,65 @@ import { useState } from 'react';
|
||||
import QuizQuestion from './components/QuizQuestion';
|
||||
import QuizResults from './components/QuizResults';
|
||||
|
||||
type PersonalityKey = 'zen' | 'nightOwl' | 'socialButterfly';
|
||||
type Stage = 'intro' | 'quiz' | 'results';
|
||||
/** @typedef {'zen' | 'nightOwl' | 'socialButterfly'} PersonalityKey */
|
||||
/** @typedef {'intro' | 'quiz' | 'results'} Stage */
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'Pick a Netflix genre for tonight:',
|
||||
options: [
|
||||
{ text: 'A dark, quiet thriller with no jump scares', personality: 'zen' as PersonalityKey },
|
||||
{ text: "A binge-worthy series you'll watch until 3am", personality: 'nightOwl' as PersonalityKey },
|
||||
{ text: 'Whatever your group chat is hyped about', personality: 'socialButterfly' as PersonalityKey },
|
||||
{ text: 'A dark, quiet thriller with no jump scares', personality: /** @type {PersonalityKey} */ ('zen') },
|
||||
{ text: "A binge-worthy series you'll watch until 3am", personality: /** @type {PersonalityKey} */ ('nightOwl') },
|
||||
{ text: 'Whatever your group chat is hyped about', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Your Hogwarts common room vibe:',
|
||||
options: [
|
||||
{ text: 'Ravenclaw — books, focus, solitude', personality: 'zen' as PersonalityKey },
|
||||
{ text: 'Slytherin — ambition, late nights, no rules', personality: 'nightOwl' as PersonalityKey },
|
||||
{ text: "Hufflepuff — warm, inclusive, everyone's welcome", personality: 'socialButterfly' as PersonalityKey },
|
||||
{ text: 'Ravenclaw — books, focus, solitude', personality: /** @type {PersonalityKey} */ ('zen') },
|
||||
{ text: 'Slytherin — ambition, late nights, no rules', personality: /** @type {PersonalityKey} */ ('nightOwl') },
|
||||
{ text: "Hufflepuff — warm, inclusive, everyone's welcome", personality: /** @type {PersonalityKey} */ ('socialButterfly') },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: "You're at a party. Where are you?",
|
||||
options: [
|
||||
{ text: 'Stepped outside for some quiet', personality: 'zen' as PersonalityKey },
|
||||
{ text: 'Still there at 2am helping close the place down', personality: 'nightOwl' as PersonalityKey },
|
||||
{ text: 'Center of the room, introducing people to each other', personality: 'socialButterfly' as PersonalityKey },
|
||||
{ text: 'Stepped outside for some quiet', personality: /** @type {PersonalityKey} */ ('zen') },
|
||||
{ text: 'Still there at 2am helping close the place down', personality: /** @type {PersonalityKey} */ ('nightOwl') },
|
||||
{ text: 'Center of the room, introducing people to each other', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Your ideal travel style:',
|
||||
options: [
|
||||
{ text: 'Solo trip, minimal itinerary, just vibes', personality: 'zen' as PersonalityKey },
|
||||
{ text: 'Red-eye flight to maximize time at the destination', personality: 'nightOwl' as PersonalityKey },
|
||||
{ text: 'Group trip, shared Airbnb, chaotic and perfect', personality: 'socialButterfly' as PersonalityKey },
|
||||
{ text: 'Solo trip, minimal itinerary, just vibes', personality: /** @type {PersonalityKey} */ ('zen') },
|
||||
{ text: 'Red-eye flight to maximize time at the destination', personality: /** @type {PersonalityKey} */ ('nightOwl') },
|
||||
{ text: 'Group trip, shared Airbnb, chaotic and perfect', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Pick a superpower:',
|
||||
options: [
|
||||
{ text: 'Total mental clarity, always focused', personality: 'zen' as PersonalityKey },
|
||||
{ text: 'Never need sleep', personality: 'nightOwl' as PersonalityKey },
|
||||
{ text: 'Everyone immediately likes you', personality: 'socialButterfly' as PersonalityKey },
|
||||
{ text: 'Total mental clarity, always focused', personality: /** @type {PersonalityKey} */ ('zen') },
|
||||
{ text: 'Never need sleep', personality: /** @type {PersonalityKey} */ ('nightOwl') },
|
||||
{ text: 'Everyone immediately likes you', personality: /** @type {PersonalityKey} */ ('socialButterfly') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const initialScores: Record<PersonalityKey, number> = { zen: 0, nightOwl: 0, socialButterfly: 0 };
|
||||
/** @type {Record<PersonalityKey, number>} */
|
||||
const initialScores = { zen: 0, nightOwl: 0, socialButterfly: 0 };
|
||||
|
||||
/**
|
||||
* @returns {import('react').JSX.Element}
|
||||
*/
|
||||
export default function Home() {
|
||||
const [stage, setStage] = useState<Stage>('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 →
|
||||
</button>
|
||||
@@ -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",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
},
|
||||
"verbatimModuleSyntax": false
|
||||
"allowJs": true,
|
||||
"checkJs": true
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
@@ -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<VertexId, Vec2> = {
|
||||
/** @type {readonly VertexId[]} */
|
||||
const VERTEX_IDS = ['a', 'b', 'c', 'ap', 'bp', 'cp'];
|
||||
|
||||
/** @type {Record<VertexId, Vec2>} */
|
||||
const INITIAL = {
|
||||
a: vec(60, 80),
|
||||
b: vec(180, 80),
|
||||
c: vec(120, 220),
|
||||
@@ -28,36 +33,60 @@ const INITIAL: Record<VertexId, Vec2> = {
|
||||
cp: vec(280, 220),
|
||||
};
|
||||
|
||||
interface Refs {
|
||||
svg: SVGSVGElement;
|
||||
vertices: Record<VertexId, SVGCircleElement>;
|
||||
labels: Record<VertexId, SVGTextElement>;
|
||||
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<VertexId, SVGCircleElement>} vertices
|
||||
* @property {Record<VertexId, SVGTextElement>} labels
|
||||
* @property {Record<SideKey, SVGLineElement>} sides
|
||||
* @property {Record<SideKey, SVGGElement>} ticks
|
||||
* @property {Record<SideKey, HTMLElement>} 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<VertexId, Vec2>) {
|
||||
/**
|
||||
* @param {Refs} refs
|
||||
* @param {Record<VertexId, Vec2>} 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<VertexId, Vec2>) {
|
||||
refs.badge.style.display = congruent ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function getRefs(svg: SVGSVGElement): Refs | null {
|
||||
const vertices: Partial<Record<VertexId, SVGCircleElement>> = {};
|
||||
const labels: Partial<Record<VertexId, SVGTextElement>> = {};
|
||||
/**
|
||||
* @param {SVGSVGElement} svg
|
||||
* @returns {Refs | null}
|
||||
*/
|
||||
function getRefs(svg) {
|
||||
/** @type {Partial<Record<VertexId, SVGCircleElement>>} */
|
||||
const vertices = {};
|
||||
/** @type {Partial<Record<VertexId, SVGTextElement>>} */
|
||||
const labels = {};
|
||||
for (const id of VERTEX_IDS) {
|
||||
const v = svg.querySelector<SVGCircleElement>(`[data-vertex="${id}"]`);
|
||||
const l = svg.querySelector<SVGTextElement>(`[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<Record<SideKey, SVGLineElement>> = {};
|
||||
const ticks: Partial<Record<SideKey, SVGGElement>> = {};
|
||||
const readouts: Partial<Record<SideKey, HTMLElement>> = {};
|
||||
/** @type {readonly SideKey[]} */
|
||||
const sideKeys = ['ab', 'bc', 'ca', 'apbp', 'bpcp', 'cpap'];
|
||||
/** @type {Partial<Record<SideKey, SVGLineElement>>} */
|
||||
const sides = {};
|
||||
/** @type {Partial<Record<SideKey, SVGGElement>>} */
|
||||
const ticks = {};
|
||||
/** @type {Partial<Record<SideKey, HTMLElement>>} */
|
||||
const readouts = {};
|
||||
for (const key of sideKeys) {
|
||||
const s = svg.querySelector<SVGLineElement>(`[data-side="${key}"]`);
|
||||
const t = svg.querySelector<SVGGElement>(`[data-ticks="${key}"]`);
|
||||
const r = document.querySelector<HTMLElement>(`[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<HTMLElement>('[data-badge="congruent"]');
|
||||
const badge = /** @type {HTMLElement | null} */ (
|
||||
document.querySelector('[data-badge="congruent"]')
|
||||
);
|
||||
if (!badge) return null;
|
||||
|
||||
return {
|
||||
svg,
|
||||
vertices: vertices as Record<VertexId, SVGCircleElement>,
|
||||
labels: labels as Record<VertexId, SVGTextElement>,
|
||||
sides: sides as Record<SideKey, SVGLineElement>,
|
||||
ticks: ticks as Record<SideKey, SVGGElement>,
|
||||
readouts: readouts as Record<SideKey, HTMLElement>,
|
||||
vertices: /** @type {Record<VertexId, SVGCircleElement>} */ (vertices),
|
||||
labels: /** @type {Record<VertexId, SVGTextElement>} */ (labels),
|
||||
sides: /** @type {Record<SideKey, SVGLineElement>} */ (sides),
|
||||
ticks: /** @type {Record<SideKey, SVGGElement>} */ (ticks),
|
||||
readouts: /** @type {Record<SideKey, HTMLElement>} */ (readouts),
|
||||
badge,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCongruenceSSS(svgSelector: string) {
|
||||
const svg = document.querySelector<SVGSVGElement>(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<VertexId, Vec2> = { ...INITIAL };
|
||||
/** @type {Record<VertexId, Vec2>} */
|
||||
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;
|
||||
+51
-27
@@ -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<SVGSVGElement>(svgSelector);
|
||||
/** @param {string} svgSelector */
|
||||
export function setupInscribedAngle(svgSelector) {
|
||||
const svg = /** @type {SVGSVGElement | null} */ (document.querySelector(svgSelector));
|
||||
if (!svg) return;
|
||||
const m = svg.querySelector<SVGCircleElement>('[data-vertex="M"]');
|
||||
const segAM = svg.querySelector<SVGLineElement>('[data-segment="AM"]');
|
||||
const segBM = svg.querySelector<SVGLineElement>('[data-segment="BM"]');
|
||||
const inscribedReadout = document.querySelector<HTMLElement>('[data-readout="inscribed"]');
|
||||
const centralReadout = document.querySelector<HTMLElement>('[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);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
|
||||
root.querySelector<T>(sel);
|
||||
|
||||
const ap = q<SVGCircleElement>('[data-vertex="ap"]', svg);
|
||||
const bp = q<SVGCircleElement>('[data-vertex="bp"]', svg);
|
||||
const cp = q<SVGCircleElement>('[data-vertex="cp"]', svg);
|
||||
const apLabel = q<SVGTextElement>('[data-vertex-label="ap"]', svg);
|
||||
const bpLabel = q<SVGTextElement>('[data-vertex-label="bp"]', svg);
|
||||
const cpLabel = q<SVGTextElement>('[data-vertex-label="cp"]', svg);
|
||||
const apbp = q<SVGLineElement>('[data-side="apbp"]', svg);
|
||||
const bpcp = q<SVGLineElement>('[data-side="bpcp"]', svg);
|
||||
const cpap = q<SVGLineElement>('[data-side="cpap"]', svg);
|
||||
const tickApBp = q<SVGGElement>('[data-ticks="apbp"]', svg);
|
||||
const tickBpCp = q<SVGGElement>('[data-ticks="bpcp"]', svg);
|
||||
const tickCpAp = q<SVGGElement>('[data-ticks="cpap"]', svg);
|
||||
|
||||
const kReadout = q<HTMLElement>('[data-readout="k"]');
|
||||
const kSlider = q<HTMLInputElement>('[data-control="k-slider"]');
|
||||
const apbpReadout = q<HTMLElement>('[data-readout-side="apbp"]');
|
||||
const bpcpReadout = q<HTMLElement>('[data-readout-side="bpcp"]');
|
||||
const cpapReadout = q<HTMLElement>('[data-readout-side="cpap"]');
|
||||
const ratioAB = q<HTMLElement>('[data-readout-ratio="ab"]');
|
||||
const ratioBC = q<HTMLElement>('[data-readout-ratio="bc"]');
|
||||
const ratioCA = q<HTMLElement>('[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<SVGSVGElement>(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<SVGGElement>('[data-ticks="ab"]');
|
||||
const tickBC = svg.querySelector<SVGGElement>('[data-ticks="bc"]');
|
||||
const tickCA = svg.querySelector<SVGGElement>('[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 });
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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'] },
|
||||
];
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Screen>("menu");
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("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");
|
||||
+13
-7
@@ -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 (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
+15
-9
@@ -1,18 +1,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import Phaser from "phaser";
|
||||
import { createPhaserConfig } from "../phaser/config";
|
||||
import { Difficulty } from "../types";
|
||||
import { GameStateManager } from "../game/state";
|
||||
|
||||
interface GameContainerProps {
|
||||
difficulty: Difficulty;
|
||||
stateManager: GameStateManager;
|
||||
onGameOver: () => void;
|
||||
}
|
||||
/** @typedef {import("../types").Difficulty} Difficulty */
|
||||
|
||||
export function GameContainer({ difficulty, stateManager, onGameOver }: GameContainerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gameRef = useRef<Phaser.Game | null>(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;
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
visible: boolean;
|
||||
onHide: () => 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(() => {
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Difficulty, DifficultyConfig } from "../types";
|
||||
/**
|
||||
* @typedef {import("../types").Difficulty} Difficulty
|
||||
* @typedef {import("../types").DifficultyConfig} DifficultyConfig
|
||||
*/
|
||||
|
||||
export const DIFFICULTY_CONFIGS: Record<Difficulty, DifficultyConfig> = {
|
||||
/** @type {Record<Difficulty, DifficultyConfig>} */
|
||||
export const DIFFICULTY_CONFIGS = {
|
||||
easy: {
|
||||
rows: 4,
|
||||
cols: 6,
|
||||
@@ -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--) {
|
||||
@@ -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<string, Point[]>();
|
||||
/** @type {Map<string, Point[]>} */
|
||||
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) {
|
||||
@@ -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;
|
||||
@@ -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<string, Listener[]> = new Map();
|
||||
|
||||
constructor() {
|
||||
/** @type {GameState} */
|
||||
this.state = {
|
||||
status: "menu",
|
||||
difficulty: null,
|
||||
@@ -29,20 +32,33 @@ export class GameStateManager {
|
||||
combo: 1,
|
||||
lastMatchTime: 0,
|
||||
};
|
||||
/** @type {Map<string, Listener[]>} */
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
getState(): Readonly<GameState> {
|
||||
/** @returns {Readonly<GameState>} */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
@@ -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,
|
||||
+103
-39
@@ -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<string, Phaser.GameObjects.Container> = 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<string, Phaser.GameObjects.Container>} */
|
||||
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<string, unknown>).solve = () => this.startSolve();
|
||||
/** @type {Record<string, unknown>} */ (/** @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;
|
||||
+4
-2
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ describe("createBoard", () => {
|
||||
|
||||
it("every tile has a matching pair", () => {
|
||||
const board = createBoard("easy");
|
||||
const emojiCounts = new Map<string, number>();
|
||||
/** @type {Map<string, number>} */
|
||||
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<number>();
|
||||
/** @type {Set<number>} */
|
||||
const ids = new Set();
|
||||
for (const row of board) {
|
||||
for (const cell of row) {
|
||||
if (cell) {
|
||||
+12
-7
@@ -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<typeof path>} */ (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<typeof path>} */ (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<typeof path>} */ (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<typeof path>} */ (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<typeof path>} */ (path).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user