From a8938885c9bc3e5d114d569f7c09cec9d2a1941f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 13:41:52 +0000 Subject: [PATCH] Add template generalization features: rename script, common components, multi-screen guide - scripts/rename-sim.ts: automated fork/rename replaces all template identifiers in file content and renames files/folders in one pass; registered as `npm run rename` - src/common/SimPanel.ts: pre-themed Panel wrapper that uses SimColors automatically; every control panel in a forked sim should use this - src/common/TimeModel.ts: composable play/pause + elapsed-time model for animated sims; compose into screen model, wire view to TimeControlNode - doc/multi-screen.md: complete guide covering independent vs. shared-model architectures, file structure, StringManager changes, home-screen icons, per-screen a11y strings, and usage beyond direct copy (GitHub template, monorepo, npm create, git subtree) - CLAUDE.md: updated with new common-component API examples, multi-screen summary, rename-script instructions, and usage-beyond-copy table - doc/implementation-notes.md: updated architecture diagram and fork checklist to reflect new common components and rename script https://claude.ai/code/session_01HGpwLPoEwEd4yAqLJPUmAn --- CLAUDE.md | 76 +++++++++- doc/implementation-notes.md | 106 ++++++++++--- doc/multi-screen.md | 287 ++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/rename-sim.ts | 179 ++++++++++++++++++++++ src/common/SimPanel.ts | 44 ++++++ src/common/TimeModel.ts | 83 +++++++++++ 7 files changed, 752 insertions(+), 24 deletions(-) create mode 100644 doc/multi-screen.md create mode 100644 scripts/rename-sim.ts create mode 100644 src/common/SimPanel.ts create mode 100644 src/common/TimeModel.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7216670..27111ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Sim-specific context for AI assistants. General SceneryStack guidance: [OpenPhys ## Project -Reusable single-screen SceneryStack template. When forking, search-and-replace `sim-template` / `SimTemplate` / `Sim Template` / `SimModel` / `SimScreen` throughout. +Reusable single-screen SceneryStack template. Run `npm run rename` to fork it to a new sim name automatically. For multi-screen sims, see `doc/multi-screen.md`. ## Key files @@ -18,7 +18,44 @@ Reusable single-screen SceneryStack template. When forking, search-and-replace ` | `src/sim-screen/view/SimScreenView.ts` | Visual nodes, layout, `screenSummaryContent` + `pdomOrder` | | `src/sim-screen/view/SimScreenSummaryContent.ts` | Accessible screen summary (reference a11y pattern) | | `src/sim-screen/view/SimKeyboardHelpContent.ts` | Keyboard-help dialog content | +| `src/common/SimPanel.ts` | Pre-themed `Panel` wrapper (uses `SimColors` automatically) | +| `src/common/TimeModel.ts` | Composable play/pause + elapsed-time model for animated sims | | `scripts/generate-icons.ts` | PNG icons from `public/icons/icon.svg` | +| `scripts/rename-sim.ts` | Automated fork/rename across all files and folders | + +## Common components + +### SimPanel + +Every control panel and info box in the sim should use `SimPanel` so that +default/projector color switching is automatic: + +```typescript +import { SimPanel } from "../../common/SimPanel.js"; +const panel = new SimPanel(content); // uses SimColors defaults +const panel = new SimPanel(content, { xMargin: 20 }); // override any PanelOption +``` + +### TimeModel + +For simulations with animation, compose `TimeModel` into your screen model: + +```typescript +import { TimeModel } from "../../common/TimeModel.js"; + +export class FrictionModel implements TModel { + public readonly timer = new TimeModel(); // starts paused; pass true to auto-play + + public step(dt: number): void { + this.timer.step(dt); + // use this.timer.timeProperty.value for physics + } + public reset(): void { this.timer.reset(); /* … */ } +} +``` + +Wire the view to `TimeControlNode` from `scenerystack/scenery-phet` binding on +`model.timer.isPlayingProperty`. ## Accessibility @@ -31,11 +68,46 @@ every interactive node. Full convention and checklist: [../ACCESSIBILITY.md](../ ## Customizing a new sim from this template -1. **Rename** — replace template identifiers in `init.ts`, `brand.ts`, `package.json`, and screen folders +### Automated rename (recommended) + +```sh +npm run rename -- --id friction --name "Friction" +# or for multi-word names: +npm run rename -- --id wave-interference --name "Wave Interference" +``` + +This replaces all template identifiers in file contents and renames files/folders. Run `npm run check` afterwards to verify TypeScript is clean. + +### Manual checklist (if not using the rename script) + +1. **Rename** — replace `sim-template` / `Sim Template` / `Sim` prefix in `init.ts`, `brand.ts`, `package.json`, class names, and screen folders 2. **Locale** — add `strings_XX.json`, register in `StringManager`, add locale to `init.ts` `availableLocales` 3. **Icon** — edit `public/icons/icon.svg`, run `npm run icons`; match theme color in `index.html` / `vite.config.ts` 4. **Colors** — edit `SimColors.ts` (`default` + `projector` profiles per property) +## Multi-screen sims + +Full guide: **`doc/multi-screen.md`** + +Summary: +- Create a new screen folder mirroring `src/sim-screen/` for each screen +- Add screen-name keys to all locale JSON files +- Expose new `StringProperty` getters in `StringManager.getScreenNames()` +- For shared state, create a root model passed to each per-screen model +- Register all screens in the `screens` array in `main.ts` + +## Using this template beyond a direct copy + +| Approach | When to use | +|---|---| +| **GitHub template** ("Use this template" button) | Starting a single new sim | +| `npm run rename` after cloning | Same, automated | +| **npm workspace / monorepo** | Managing a suite of sims with shared tooling | +| **`npm create` scaffolder** | Org-wide standardized sim bootstrapping | +| **git subtree** for pulling updates | Keeping forks in sync with template improvements | + +See `doc/multi-screen.md` → "Using this template beyond a direct copy" for details on each approach. + ## PWA After `npm run build`, the sim is installable offline via Workbox (`dist/manifest.webmanifest`). diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index 9102f81..e696e06 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -2,51 +2,113 @@ ## Architecture Overview -TemplateSingleSim is a minimal starter scaffold for forking new single-screen SceneryStack simulations. It demonstrates the Model-View pattern, color profiles, localization, and reset behavior without domain-specific physics. +TemplateSingleSim is a minimal starter scaffold for forking new single-screen SceneryStack simulations. It demonstrates the Model-View pattern, color profiles, localization, reset behavior, and reusable common components without domain-specific physics. ### High-Level Architecture -The simulation follows a modular architecture: +``` +main.ts + └─ SimScreen (Screen) + ├─ SimModel state + logic (src/sim-screen/model/) + └─ SimScreenView visuals (src/sim-screen/view/) + ├─ SimScreenSummaryContent (PDOM overview) + └─ SimKeyboardHelpContent (keyboard help dialog) -- **Model Layer (`src/sim-screen/model/`)**: Stub model with TODO hooks for `step()` and `reset()` -- **View Layer (`src/sim-screen/view/`)**: Placeholder background, label, and Reset All button -- **Bootstrap**: `brand.js` must load first in `main.ts`; `init.ts` configures locales and splash +src/common/ + ├─ SimPanel.ts pre-themed panel (all screens share SimColors) + └─ TimeModel.ts composable play/pause + elapsed time -Data flows from Model → View through AXON-ready property patterns documented in `SimModel.ts`. +src/preferences/ + ├─ SimPreferencesModel sim-specific pref state + ├─ SimPreferencesNode pref UI shown in Preferences → Simulation + └─ simQueryParameters query-parameter declarations +``` + +Data flows Model → View through AXON `Property` objects. The view observes +properties via `.link()` or `.lazyLink()` and updates reactively. ## Model Components -### Core Model Design +### SimModel + +An empty coordinator with documented hooks for `step(dt)` and `reset()`. +Add physics state as `BooleanProperty`, `NumberProperty`, etc. from +`scenerystack/axon`. + +### TimeModel (common) -`SimModel` is an empty coordinator with commented examples for observable properties and simulation stepping. +`src/common/TimeModel.ts` is a reusable play/pause + elapsed-time model for +animated sims. Compose it into your screen model rather than subclassing: -When forking this template: +```typescript +export class YourModel implements TModel { + public readonly timer = new TimeModel(); -1. Rename `SimModel`, `SimScreen`, and `SimScreenView` to match the new sim name -2. Replace `SimColors.ts` and `SimNamespace.ts` with sim-specific files -3. Add physics logic in `step()` and state restoration in `reset()` + public step(dt: number): void { + this.timer.step(dt); + // physics driven by this.timer.timeProperty.value + } + public reset(): void { this.timer.reset(); } +} +``` ## View Components ### SimScreenView as Coordinator -The screen view demonstrates layout using `layoutBounds`, background fill from `SimColors.ts`, and a `ResetAllButton` wired to `model.reset()`. +The screen view demonstrates layout using `layoutBounds`, background fill from +`SimColors.ts`, and a `ResetAllButton` wired to `model.reset()`. Add +specialized sub-nodes under `src/sim-screen/view/`. -When extending the view: +### SimPanel (common) -- Add specialized nodes under `src/sim-screen/view/` -- Keep colors in `SimColors.ts` and strings in `src/i18n/strings_*.json` -- Run `scripts/generate-icons.ts` after updating branding assets +`src/common/SimPanel.ts` wraps SceneryStack's `Panel` with the sim's color +scheme baked in. All control panels should use `SimPanel` so projector-mode +switching is automatic: + +```typescript +const panel = new SimPanel(content); // defaults +const panel = new SimPanel(content, { xMargin: 20 }); // any PanelOption override +``` ### Color Scheme -`SimColors.ts` defines `ProfileColorProperty` instances for default and projector profiles. This is the pattern all forked sims should follow. +`SimColors.ts` defines `ProfileColorProperty` instances for "default" (dark) +and "projector" (light) profiles. SceneryStack switches profiles automatically +when the user toggles Projector Mode in Preferences. + +## Forking this template + +### Automated rename -### Fork Checklist +```sh +npm run rename -- --id friction --name "Friction" +npm run check +``` -- Update package name, sim title, and locale files (en, es, fr) -- Regenerate PWA icons and splash assets +`scripts/rename-sim.ts` replaces all template identifiers in file content and +renames files and folders in one pass. + +### Manual fork checklist + +- Update `package.json` name, `init.ts` name/version, `brand.ts` - Replace placeholder view content with play area and control panels +- Replace `SimColors.ts` colors with sim-specific palette +- Update locale JSON files: title, screen names, a11y strings +- Regenerate PWA icons (`npm run icons`) after editing `public/icons/icon.svg` - Add `doc/implementation-notes.md` describing the new sim's architecture -Note that no dispose functions have been used, which should be addressed once listeners are added. +## Multi-screen simulations + +See `doc/multi-screen.md` for a complete guide covering: +- Independent vs. shared-model architectures +- File structure for each screen +- StringManager and locale changes +- Home-screen icon requirements +- Per-screen accessibility strings + +## Known gaps / TODOs + +- No dispose() calls yet — add them once Properties gain external listeners. +- `SimModel.step()` and `reset()` bodies are stubs — fill in with real physics. +- `SimScreenView` pdomOrder TODO comment — add interactive nodes as they are created. diff --git a/doc/multi-screen.md b/doc/multi-screen.md new file mode 100644 index 0000000..d97a489 --- /dev/null +++ b/doc/multi-screen.md @@ -0,0 +1,287 @@ +# Multi-Screen Simulations + +This template ships as a **single-screen** simulation. Many physics simulations +expose multiple conceptual modes — "Intro" + "Lab", "Basics" + "Advanced", etc. +This guide shows how to extend the template to two or more screens. + +--- + +## Architecture patterns + +### Single-screen (template default) + +``` +main.ts + └─ SimScreen (Screen) + ├─ SimModel owns all state + └─ SimScreenView owns all visuals +``` + +### Multi-screen with independent state (simplest) + +Each screen is completely self-contained. Use this when screens have no shared +physical state — for instance an "Intro" that is purely explanatory and a "Lab" +with interactive controls. + +``` +main.ts + ├─ IntroScreen (Screen) + │ ├─ IntroModel + │ └─ IntroScreenView + └─ LabScreen (Screen) + ├─ LabModel + └─ LabScreenView +``` + +### Multi-screen with shared model (recommended for real sims) + +A top-level "root model" owns shared state (e.g. selected material, common +parameters). Each screen model receives a reference to it. + +``` +main.ts → creates FrictionModel (shared) + ├─ IntroScreen receives FrictionModel → IntroModel(frictionModel) + └─ LabScreen receives FrictionModel → LabModel(frictionModel) +``` + +--- + +## Step-by-step: adding a second screen + +### 1 — Add strings + +`src/i18n/strings_en.json` (and every other locale file): + +```json +{ + "title": "Friction", + "screens": { + "intro": "Intro", + "lab": "Lab" + } +} +``` + +**Important:** All locale files must define identical keys. TypeScript will error +at compile time if any key is missing (see the `satisfies` checks in +`StringManager.ts`). + +### 2 — Expose screen-name properties in StringManager + +```typescript +// src/i18n/StringManager.ts +public getScreenNames(): { + readonly introStringProperty: ReadOnlyProperty; + readonly labStringProperty: ReadOnlyProperty; +} { + return { + introStringProperty: stringProperties.screens.introStringProperty, + labStringProperty: stringProperties.screens.labStringProperty, + }; +} +``` + +### 3 — Create the second screen folder + +Mirror the structure of `src/sim-screen/`: + +``` +src/ +├─ intro-screen/ +│ ├─ IntroScreen.ts +│ ├─ model/ +│ │ └─ IntroModel.ts +│ └─ view/ +│ ├─ IntroScreenView.ts +│ ├─ IntroScreenSummaryContent.ts +│ └─ IntroKeyboardHelpContent.ts +└─ lab-screen/ + ├─ LabScreen.ts + ├─ model/ + │ └─ LabModel.ts + └─ view/ + ├─ LabScreenView.ts + ├─ LabScreenSummaryContent.ts + └─ LabKeyboardHelpContent.ts +``` + +Each screen file follows the same `Screen` pattern as the +existing `SimScreen.ts`. + +### 4 — (Optional) Create a shared root model + +If screens share state, create a top-level model before constructing screens: + +```typescript +// src/model/FrictionModel.ts +import { BooleanProperty, NumberProperty } from "scenerystack/axon"; + +export class FrictionModel { + public readonly surfaceTypeProperty = new StringProperty("wood"); + public readonly normalForceProperty = new NumberProperty(10, { units: "N" }); + + public reset(): void { + this.surfaceTypeProperty.reset(); + this.normalForceProperty.reset(); + } +} +``` + +Per-screen models then take it as a constructor argument: + +```typescript +// src/intro-screen/model/IntroModel.ts +export class IntroModel implements TModel { + public constructor(public readonly shared: FrictionModel) {} + + public step(_dt: number): void { /* … */ } + public reset(): void { this.shared.reset(); } +} +``` + +### 5 — Register both screens in main.ts + +```typescript +// src/main.ts (inside onReadyToLaunch) + +// Shared model — created once, passed to both screens +const frictionModel = new FrictionModel(); + +const screens = [ + new IntroScreen(frictionModel, { + name: stringManager.getScreenNames().introStringProperty, + tandem: Tandem.ROOT.createTandem("introScreen"), + backgroundColorProperty: SimColors.backgroundColorProperty, + }), + new LabScreen(frictionModel, { + name: stringManager.getScreenNames().labStringProperty, + tandem: Tandem.ROOT.createTandem("labScreen"), + backgroundColorProperty: SimColors.backgroundColorProperty, + }), +]; + +const sim = new Sim(stringManager.getTitleStringProperty(), screens, { … }); +``` + +--- + +## Screen options reference + +| Option | Type | Purpose | +|---|---|---| +| `name` | `ReadOnlyProperty` | Localizable tab label | +| `tandem` | `Tandem` | PhET-iO registration root | +| `backgroundColorProperty` | `TReadOnlyProperty` | Screen background | +| `createKeyboardHelpNode` | `() => Node` | Per-screen keyboard help | +| `homeScreenIcon` | `ScreenIcon` | Icon on the home screen | +| `navigationBarIcon` | `ScreenIcon` | Smaller icon in the nav bar | +| `maxDT` | `number` | Maximum allowed dt in seconds | +| `targetFrameRate` | `number` | Target FPS for `step()` | + +--- + +## Home screen icons + +Multi-screen sims show a home screen by default. Each screen needs a 548×373 px +`ScreenIcon` (or the SceneryStack default is used): + +```typescript +import { ScreenIcon } from "scenerystack/sim"; +import { Rectangle } from "scenerystack/scenery"; + +const icon = new ScreenIcon( + new Rectangle(0, 0, 548, 373, { fill: SimColors.accentColorProperty }), + { maxIconWidthProportion: 1, maxIconHeightProportion: 1 } +); +``` + +Pass it as `homeScreenIcon` and `navigationBarIcon` on the Screen options. + +--- + +## Accessibility across screens + +Each screen must have its own `ScreenSummaryContent` and `KeyboardHelpContent`. +The strings live under per-screen keys in the a11y block: + +```json +"a11y": { + "intro": { + "screenSummary": { … }, + "currentDetails": "…" + }, + "lab": { + "screenSummary": { … }, + "currentDetails": "…" + } +} +``` + +Expose them via separate methods in `StringManager`: + +```typescript +public getIntroA11yStrings() { return stringProperties.a11y.intro; } +public getLabA11yStrings() { return stringProperties.a11y.lab; } +``` + +--- + +## Using this template beyond a direct copy + +### GitHub template repository + +The repository is configured as a GitHub template. Use the **"Use this +template"** button on GitHub to create a new repository pre-populated with all +template files. Then run: + +```sh +npm install +npm run rename -- --id my-sim --name "My Simulation" +npm run check +``` + +### `npm create` workflow (scaffold new projects) + +If your organisation maintains multiple sims, create an npm initializer that +wraps the rename step: + +```sh +npm create openphysics-sim@latest my-sim +# → clones the template, runs npm run rename automatically +``` + +See `scripts/rename-sim.ts` for the rename logic you can reuse. + +### Monorepo / workspace setup + +For organisations building a suite of simulations, a pnpm/npm workspace lets +you share tooling while keeping each sim independent: + +``` +physics-sims/ +├─ package.json # workspace root (workspaces: ["sims/*"]) +├─ sims/ +│ ├─ friction/ # forked from this template +│ ├─ waves/ +│ └─ optics/ +└─ shared/ # optional: shared assets, design tokens +``` + +Each sim is still independently deployable; the workspace just gives you a +single `npm run build --workspaces` command to build all of them. + +### Git subtree for template updates + +To pull template improvements back into an existing fork: + +```sh +# One-time: add the template as a remote +git remote add template https://github.com/OpenPhysics/TemplateSingleSim.git + +# Pull template changes into a branch for review +git fetch template +git merge template/main --allow-unrelated-histories --squash +``` + +Review the diff carefully — class-name changes in the template may conflict +with your sim-specific renames. diff --git a/package.json b/package.json index 8a10618..5ad6865 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "fix": "biome check --write .", "check": "tsc --noEmit && tsc -p tsconfig.scripts.json --noEmit", "icons": "tsx scripts/generate-icons.ts", + "rename": "tsx scripts/rename-sim.ts", "clean": "rm -rf dist", "prepare": "git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath .githooks || true" }, diff --git a/scripts/rename-sim.ts b/scripts/rename-sim.ts new file mode 100644 index 0000000..30df39d --- /dev/null +++ b/scripts/rename-sim.ts @@ -0,0 +1,179 @@ +#!/usr/bin/env tsx +/** + * scripts/rename-sim.ts + * + * Renames the sim template for a new simulation. Replaces all template + * identifiers in file contents, then renames files and directories. + * + * Usage: + * npm run rename -- --id --name "" + * + * Examples: + * npm run rename -- --id friction --name "Friction" + * npm run rename -- --id wave-interference --name "Wave Interference" + * + * The class prefix is derived automatically: + * "Friction" → prefix "Friction" + * "Wave Interference" → prefix "WaveInterference" + * + * Override the prefix explicitly with --prefix: + * npm run rename -- --id my-sim --name "My Simulation" --prefix MySim + * + * After running: + * npm run check ← verify TypeScript is clean + * git diff --stat ← review all changes + */ + +import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// ── Argument parsing ────────────────────────────────────────────────────────── + +function getArg(flag: string): string | undefined { + const i = process.argv.indexOf(flag); + return i !== -1 ? process.argv[i + 1] : undefined; +} + +const newId = getArg("--id"); +const newName = getArg("--name"); + +if (!newId || !newName) { + console.error("Usage: npm run rename -- --id --name \"\""); + console.error(""); + console.error("Examples:"); + console.error(" npm run rename -- --id friction --name \"Friction\""); + console.error(" npm run rename -- --id wave-interference --name \"Wave Interference\""); + process.exit(1); +} + +// PascalCase class prefix: "Wave Interference" → "WaveInterference" +const newPrefix = + getArg("--prefix") ?? + newName + .split(/\s+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); + +// camelCase prefix: "WaveInterference" → "waveInterference" +const newCamel = newPrefix.charAt(0).toLowerCase() + newPrefix.slice(1); + +const ROOT = resolve(process.cwd()); + +// ── Skip lists ──────────────────────────────────────────────────────────────── + +const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".cache", ".vite"]); +const TEXT_EXTS = new Set([".ts", ".js", ".json", ".html", ".md", ".css", ".svg", ".txt", ".webmanifest", ".toml"]); + +// ── Content replacements ────────────────────────────────────────────────────── +// Longer, more-specific strings must come first to avoid partial matches. + +const REPLACEMENTS: ReadonlyArray<[string, string]> = [ + // Class names (longest first to avoid prefix collisions) + ["SimScreenSummaryContent", `${newPrefix}ScreenSummaryContent`], + ["SimKeyboardHelpContent", `${newPrefix}KeyboardHelpContent`], + ["SimPreferencesModel", `${newPrefix}PreferencesModel`], + ["SimPreferencesNode", `${newPrefix}PreferencesNode`], + ["SimScreenView", `${newPrefix}ScreenView`], + ["SimColors", `${newPrefix}Colors`], + ["SimNamespace", `${newPrefix}Namespace`], + ["SimScreen", `${newPrefix}Screen`], + ["SimModel", `${newPrefix}Model`], + // camelCase identifier + ["simQueryParameters", `${newCamel}QueryParameters`], + // Display strings + ["Sim Template", newName], + // Kebab identifiers (path segments and package name) + ["sim-template", newId], + ["sim-screen", `${newId}-screen`], +]; + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function replaceAll(str: string, search: string, replacement: string): string { + return str.split(search).join(replacement); +} + +function applyReplacements(text: string): string { + let result = text; + for (const [search, replacement] of REPLACEMENTS) { + if (search !== replacement) { + result = replaceAll(result, search, replacement); + } + } + return result; +} + +function fileExtension(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot !== -1 ? filename.slice(dot) : ""; +} + +// ── Pass 1: update file contents ────────────────────────────────────────────── + +function processContents(dir: string): void { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + processContents(full); + } else if (TEXT_EXTS.has(fileExtension(entry))) { + const original = readFileSync(full, "utf8"); + const transformed = applyReplacements(original); + if (transformed !== original) { + writeFileSync(full, transformed, "utf8"); + console.log(` updated ${full.slice(ROOT.length + 1)}`); + } + } + } +} + +// ── Pass 2: rename files and directories (children before parents) ──────────── + +interface RenameOp { + from: string; + to: string; +} + +function collectRenames(dir: string, ops: RenameOp[]): void { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectRenames(full, ops); + } + const newEntry = applyReplacements(entry); + if (newEntry !== entry) { + ops.push({ from: full, to: join(dir, newEntry) }); + } + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +console.log("\nRenaming sim template →", newName); +console.log(` id: sim-template → ${newId}`); +console.log(` name: Sim Template → ${newName}`); +console.log(` prefix: Sim → ${newPrefix}`); +console.log(` camel: sim → ${newCamel}`); +console.log(""); + +console.log("Pass 1: updating file contents…"); +processContents(ROOT); + +console.log("\nPass 2: renaming files and directories…"); +const ops: RenameOp[] = []; +collectRenames(ROOT, ops); +for (const { from, to } of ops) { + renameSync(from, to); + console.log(` renamed ${from.slice(ROOT.length + 1)} → ${to.slice(ROOT.length + 1)}`); +} + +console.log("\nDone."); +console.log("\nNext steps:"); +console.log(' 1. Update the "screens" key in strings_en.json (and other locales)'); +console.log(' if you want a per-screen identifier other than "sim".'); +console.log(" 2. Update StringManager.getScreenNames() to match the JSON key."); +console.log(" 3. npm run check ← verify TypeScript is clean"); +console.log(" 4. git diff --stat ← review all changes"); +console.log(" 5. Update doc/implementation-notes.md for your simulation."); diff --git a/src/common/SimPanel.ts b/src/common/SimPanel.ts new file mode 100644 index 0000000..a9e3365 --- /dev/null +++ b/src/common/SimPanel.ts @@ -0,0 +1,44 @@ +/** + * SimPanel.ts + * + * A pre-themed Panel that automatically uses SimColors for background and + * border. Use this for all control panels and info boxes in the sim so that + * default / projector mode switching is handled automatically. + * + * ── Basic usage ─────────────────────────────────────────────────────────────── + * + * import { SimPanel } from "../../common/SimPanel.js"; + * import { VBox, Text } from "scenerystack/scenery"; + * + * const content = new VBox({ + * children: [ new Text("label"), slider ], + * spacing: 8, + * }); + * const panel = new SimPanel(content); + * + * ── Overriding defaults ─────────────────────────────────────────────────────── + * + * // Wider margins, sharper corners, custom stroke + * const panel = new SimPanel(content, { xMargin: 20, cornerRadius: 0 }); + * + * // Transparent background (decorative border only) + * const panel = new SimPanel(content, { fill: "transparent" }); + */ + +import type { Node } from "scenerystack/scenery"; +import type { PanelOptions } from "scenerystack/sun"; +import { Panel } from "scenerystack/sun"; +import SimColors from "../SimColors.js"; + +export class SimPanel extends Panel { + public constructor(content: Node, providedOptions?: PanelOptions) { + super(content, { + fill: SimColors.panelBackgroundColorProperty, + stroke: SimColors.panelBorderColorProperty, + cornerRadius: 6, + xMargin: 12, + yMargin: 10, + ...providedOptions, + }); + } +} diff --git a/src/common/TimeModel.ts b/src/common/TimeModel.ts new file mode 100644 index 0000000..a29ce9f --- /dev/null +++ b/src/common/TimeModel.ts @@ -0,0 +1,83 @@ +/** + * TimeModel.ts + * + * A reusable, composable timing model for simulations that need play/pause and + * elapsed-time tracking. Compose it into your screen model rather than + * extending it. + * + * ── Usage ───────────────────────────────────────────────────────────────────── + * + * // In YourModel.ts + * import { TimeModel } from "../../common/TimeModel.js"; + * + * export class YourModel implements TModel { + * public readonly timer = new TimeModel(); + * + * public step( dt: number ): void { + * this.timer.step( dt ); + * // use this.timer.timeProperty.value for physics calculations + * } + * + * public reset(): void { + * this.timer.reset(); + * // reset other state … + * } + * } + * + * ── View wiring ─────────────────────────────────────────────────────────────── + * + * SceneryStack ships a TimeControlNode that binds directly to isPlayingProperty: + * + * import { TimeControlNode } from "scenerystack/scenery-phet"; + * + * const timeControl = new TimeControlNode( model.timer.isPlayingProperty, { + * timeSpeedProperty: model.timer.timeSpeedProperty, // optional + * playPauseStepButtonOptions: { + * stepForwardButtonOptions: { + * listener: () => model.step( 1 / 60 ), + * }, + * }, + * }); + * + * ── Start paused vs. playing ────────────────────────────────────────────────── + * + * new TimeModel() // starts paused (most physics sims) + * new TimeModel( true ) // starts playing (continuous animations) + */ + +import { BooleanProperty, NumberProperty } from "scenerystack/axon"; + +export class TimeModel { + /** Whether the simulation clock is running. Bind to TimeControlNode. */ + public readonly isPlayingProperty: BooleanProperty; + + /** Elapsed simulation time in seconds. Resets to 0 on reset(). */ + public readonly timeProperty: NumberProperty; + + public constructor(initiallyPlaying = false) { + this.isPlayingProperty = new BooleanProperty(initiallyPlaying); + this.timeProperty = new NumberProperty(0, { units: "s" }); + } + + /** + * Advance the simulation clock by dt seconds. + * Call this from your model's step() method. + */ + public step(dt: number): void { + if (this.isPlayingProperty.value) { + this.timeProperty.value += dt; + } + } + + /** Resets clock and playback state to their initial values. */ + public reset(): void { + this.isPlayingProperty.reset(); + this.timeProperty.reset(); + } + + /** Call when the model is no longer needed to free AXON listeners. */ + public dispose(): void { + this.isPlayingProperty.dispose(); + this.timeProperty.dispose(); + } +}