diff --git a/API.md b/API.md index f791f4a..23d424d 100644 --- a/API.md +++ b/API.md @@ -111,6 +111,54 @@ const heading = helpers.findHeading(enemy, player); enemy.heading = helpers.rotateTo(heading.angle, enemy.heading, 3); ``` +## ๐Ÿ—บ๏ธ String Tile Maps + +Use these helpers to author grid maps as text, then query cells by row/column or +centered world coordinates. + +| Export | Use It For | +| --- | --- | +| `parseStringTileMap` | Parse a multiline string into padded tile rows, with optional tile normalization. | +| `findStringTileMapCell` | Find the first cell containing a tile symbol. | +| `findStringTileMapCells` | Find all cells containing a tile symbol. | +| `getStringTileMapTile` | Read one tile by column and row. | +| `getStringTileMapCenteredPoint` | Convert a column/row into centered `x`/`z` coordinates. | +| `getStringTileMapCellFromCenteredPoint` | Convert centered `x`/`z` coordinates back into a cell. | + +```ts +const map = parseStringTileMap( + ` +########D######## +# S # +# P o # +################# +`.trim() +); +const spawn = findStringTileMapCell(map, "S"); +``` + +The isometric dungeon demo uses this editable legend on top of the generic +string map parser: + +| Symbol | Meaning | Role | +| --- | --- | --- | +| `space` | Floor | Walkable floor | +| `#` | Stone wall | Blocking wall | +| `.` | Floor marker | Walkable floor marker | +| `C` | Chest | Interactable prop | +| `D` | Door | Interactable doorway | +| `P` | Pillar | Blocking prop | +| `S` | Player spawn | Spawn point | +| `d` | Stairs down | Interactable stairs | +| `o` | Light source | Light | +| `r` | Rubble | Blocking prop | +| `u` | Stairs up | Interactable stairs | +| `w` | Water | Walkable slow floor | + +Ragged rows can be padded with an internal empty tile such as `_`; the dungeon +demo treats that as void space outside the playable floor and hides it from the +editor legend. + ## ๐ŸŽฎ Input Actions | Export | Use It For | @@ -325,7 +373,7 @@ achievements = addAchievementProgress( Achievement helpers are local game-state utilities. Remote leaderboard validation is handled by the high-score helpers. See -`Engine/Systems/New Helpers/Achievements` in Storybook for an interactive +`Engine/Achievements/Achievements` in Storybook for an interactive progress and unlock demo. ## ๐Ÿ† Achievement Notifications @@ -450,7 +498,7 @@ if (!trusted) { ``` Use these helpers alongside your API routes, score storage, used-receipt -updates, and rate limits. See `Engine/Systems/New Helpers/High Scores` in +updates, and rate limits. See `Engine/Player Data/High Scores` in Storybook for a local leaderboard and validation demo. ## ๐ŸŽž๏ธ Sprite Animation @@ -567,6 +615,48 @@ fillCanvasWithTrail(context, canvas, "#05070a", 0.18); drawCanvasLine(context, from, to, "#f6e05e", 2); ``` +## ๐Ÿ’ก Ray Tracing + +| Export | Use It For | +| --- | --- | +| `createRayTracingRectangle` | Create a clockwise rectangle polygon from top-left coordinates and size. | +| `createRayTracingBoundsPolygon` | Convert render bounds into a rectangular clipping polygon. | +| `getRayTracingPolygonSegments` | Convert a polygon into line segments for intersection checks. | +| `getRayTracingSegments` | Combine bounds and occluder polygons into a segment list. | +| `traceRay` | Find the nearest segment hit from an origin and angle. | +| `traceVisibilityPolygon` | Build sorted visibility hits for a light/viewpoint clipped by bounds and occluders. | +| `traceLightBounces` | Build direct visibility plus capped diffuse bounce layers. | + +```ts +const bounds = { height: canvas.height, width: canvas.width }; +const occluders = [ + createRayTracingRectangle(120, 90, 64, 48), + createRayTracingRectangle(260, 140, 96, 32), +]; +const visibility = traceVisibilityPolygon({ x: 80, y: 120 }, bounds, occluders); + +drawCanvasPolygon(context, visibility, "rgba(255, 220, 120, 0.22)"); +``` + +```ts +const layers = traceLightBounces({ x: 80, y: 120 }, bounds, occluders, { + bounces: 1, + lightColor: "#ffd36f", + surfaceColorMix: 0.4, +}); +``` + +These helpers only calculate 2D geometry. Games own the final rendering style, +color blending, gradients, shadow treatment, and interaction model. Bounce +requests are capped to `0..3` layers for now, and the demo assumes +low-reflectivity materials with steep attenuation. Bounds and occluders can +provide `surfaceColor` values so bounced layers inherit some of the material +color they hit. See +`Engine/Rendering/Ray Traced Apartment` in Storybook for a Canvas 2D +lighting demo with draggable furniture, a movable lamp, per-light intensity +controls, one bounce enabled by default, bounce attenuation tuning, a ray-guide +toggle, and monochrome TV-static flicker. + ## ๐Ÿ•น๏ธ 2.5D Projection | Export | Use It For | diff --git a/README.md b/README.md index 45e0f56..4ea6f7b 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,11 @@ helper systems could support other arcade-style browser games too. - Gravity and lightweight 2D/3D ragdoll helpers for arcade physics effects. - Canvas rendering helpers for trails, lines, polygons, hex color parsing, and shading. +- 2D ray tracing helpers for visibility polygons, line-segment ray hits, + capped light bounces, and movable occluder lighting demos. - 2.5D projection helpers for perspective lanes, isometric tiles, depth loops, and pseudo-3D arcade camera effects. +- String tile-map helpers for authoring board or room layouts as editable text. - Arcade motion helpers for first-person camera framing, side-scroller loops, jump arcs, and spatial audio pan/depth calculations. - Spatial audio math helpers for distance gain, pan, and listener/source mixes. @@ -311,7 +314,7 @@ platformers. Achievement helpers keep definition metadata separate from persisted state. Games can unlock achievements, increment progress counters, and render status -lists from the returned data. See the `Engine/Systems/Achievements/Achievements` +lists from the returned data. See the `Engine/Achievements/Achievements` Storybook story for an interactive unlock/progress example. ### Achievement Notifications @@ -321,7 +324,7 @@ context. Games provide the achievement text, optional icon frame, viewport, and render loop; the renderer owns queue timing, slide/hold/exit animation, text wrapping, and placeholder icons. -See the `Engine/Systems/Achievements/Achievement Notifications` Storybook story +See the `Engine/Achievements/Achievement Notifications` Storybook story for a popup queue demo. ### High Scores @@ -329,7 +332,7 @@ for a popup queue demo. High-score helpers support local score tables and optional remote sync. Games provide their own storage key, default scores, API path, settings normalizers, and plausibility rules. See the -`Engine/Systems/Player Data/High Scores` Storybook story for local leaderboard, +`Engine/Player Data/High Scores` Storybook story for local leaderboard, threshold, integrity, and plausibility examples. Remote leaderboard submissions can use run receipts and integrity payloads. @@ -345,7 +348,7 @@ providing reusable persistence mechanics. Games provide defaults, optional normalization, and a storage key; the store handles localStorage access, best-effort writes, reset, subscriptions, and optional DOM change events. -See the `Engine/Systems/Player Data/User Options` Storybook story for a live +See the `Engine/Player Data/User Options` Storybook story for a live options-store example. ### Runtime Utilities @@ -370,7 +373,7 @@ normalization math. Use them to offer CRT/VHS/custom settings menus, clamp untrusted stored intensities, and layer temporary runtime boosts into effective filter settings. -See the `Engine/Systems/Presentation/Display Filters` Storybook story for a +See the `Engine/Rendering/Display Filters` Storybook story for a visual preset demo. ### Screen Effects @@ -392,7 +395,7 @@ effects.render(context, { width: canvas.width, height: canvas.height }); The built-in `screen-droplets` effect uses pooled pixel-snapped rectangles for rain on a camera lens or visor. See the -`Engine/Systems/Player Effects/ScreenDroplets` Storybook story for the live +`Engine/Effects/Player/ScreenDroplets` Storybook story for the live demo. Player effects include screen droplets, fire, frost, low health, poison, shock, @@ -464,6 +467,27 @@ available to games: `fillCanvasWithTrail` accepts any valid CSS color string. Hex-specific helpers require 3 or 6 digit hex colors. +### Ray Tracing + +Ray tracing helpers calculate 2D visibility polygons from a light or viewpoint +against rectangular bounds and polygon occluders: + +- `createRayTracingRectangle(x, y, width, height)`. +- `createRayTracingBoundsPolygon(bounds)`. +- `getRayTracingPolygonSegments(polygon)`. +- `getRayTracingSegments(bounds, occluders?)`. +- `traceRay(origin, angle, segments)`. +- `traceVisibilityPolygon(origin, bounds, occluders?)`. +- `traceLightBounces(origin, bounds, occluders?, options?)`. + +Use them for Canvas 2D lighting, line-of-sight, fog-of-war, stealth vision +cones, or visibility previews. Bounds and occluders can provide surface colors +so bounced layers pick up material tint. See the +`Engine/Rendering/Ray Traced Apartment` Storybook story for +draggable furniture, a movable lamp, separate light-intensity controls, one +low-reflectivity bounce enabled by default, a bounce attenuation control, a +ray-guide toggle, and monochrome TV-static flicker. + ### 2.5D Projection The `arcade-3d` helpers are renderer-agnostic math functions for arcade-style @@ -535,10 +559,14 @@ Storybook contains live demos for the engine surface: - **Core**: `GameArena`, ticker behavior, viewport scaling, and debug vectors. - **Helpers**: math, geometry, object cloning, event binding, collisions, rotation, spawning, and 2.5D variants. -- **Systems**: input actions, local multiplayer, user options, achievements, - achievement notifications, high scores, display filters, sprite animation, - follow cameras, procedural background stars, player screen effects, - environment screen effects, atmospheric effects, and spatial-audio math. +- **Input**: input actions and local multiplayer input state. +- **Player Data**: user options and high scores. +- **Achievements**: achievement progress and notification rendering. +- **Rendering**: display filters, sprite animation, follow cameras, procedural + background stars, and ray-traced apartment lighting. +- **Effects**: player screen effects, environment screen effects, atmospheric + effects, and combined effect scenes. +- **Physics**: gravity and 2D/3D ragdolls. - **Audio**: master controls, effects, music, spatial panning, and global playback behavior. - **3D**: cube-cluster pickups and modular level pieces. diff --git a/WHATSNEW.md b/WHATSNEW.md index 03b5c12..c7a508b 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -73,6 +73,9 @@ - Added gravity and lightweight 2D/3D ragdoll helpers for arcade physics effects. - Added canvas rendering helpers for trails, lines, polygons, and color work. +- Added 2D ray tracing helpers for visibility polygons, ray/segment hits, + rectangular bounds, polygon occluders, surface colors, and capped diffuse + bounce layers. - Added 2.5D projection helpers for perspective, isometric, and looped-depth arcade scenes. - Added arcade-motion and spatial-audio math helpers for first-person framing, @@ -104,21 +107,21 @@ - Expanded the 2D and 2.5D side-scroller demos with obstacles, ladders, platforms, depth-scaled belt actors, and stompable dummy enemies. - Added cube-cluster demos for destructible pickups and modular level pieces. -- Added systems demos for input actions, local multiplayer, sprite animation, +- Added Storybook demos for input actions, local multiplayer, sprite animation, follow cameras, and spatial-audio math. -- Added systems demos for gravity and 2D/3D ragdoll helpers. -- Added a systems demo for persisted user option stores. -- Added a systems demo for CRT/VHS display filter presets. -- Added a systems demo for achievement notification popups. -- Added systems demos for achievement progress/unlocks and high-score +- Added Storybook demos for gravity and 2D/3D ragdoll helpers. +- Added a Storybook demo for persisted user option stores. +- Added a Storybook demo for CRT/VHS display filter presets. +- Added a Storybook demo for achievement notification popups. +- Added Storybook demos for achievement progress/unlocks and high-score leaderboard validation helpers, including Storybook actions and interaction checks for their controls. -- Added Player Effects stories under `Engine/Systems/Player Effects` for screen +- Added Player Effects stories under `Engine/Effects/Player` for screen droplets and player-state fire, frost, poison, low-health, shock, and speed-boost overlays. -- Added Screen Effects stories under `Engine/Systems/Screen Effects` for heat, +- Added Environment Effects stories under `Engine/Effects/Environment` for heat, frost, fire, and underwater environmental overlays. -- Added Atmospheric Effects stories under `Engine/Systems/Atmospheric Effects` +- Added Atmospheric Effects stories under `Engine/Effects/Atmospheric` for rain, snow, ash, and embers. - Replaced flat screen-effect backgrounds with a reusable pixel-art FPS corridor demo scene and fixed HUD weapon asset so effects can be evaluated over a @@ -127,6 +130,10 @@ position, scale, opacity, and target FPS. - Added a Presentation story for procedural stars with forward, reverse, strafe, climb, and calm motion presets. +- Added a Presentation story for a ray-traced top-down apartment with draggable + furniture, a movable lamp, per-light intensity controls, blue window light, + warm lamp light, one bounce enabled by default, bounce attenuation tuning, a + ray-guide toggle, material-tinted bounces, and monochrome TV-static flicker. - Added a GitHub Pages workflow that deploys Storybook from `storybook-static` without adding Storybook output to the npm package build. diff --git a/package-lock.json b/package-lock.json index 7860f4e..3712a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arcade-engine", - "version": "4.7.0", + "version": "4.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arcade-engine", - "version": "4.7.0", + "version": "4.10.2", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 8449793..bca451e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arcade-engine", "description": "A small browser arcade-game engine for canvas games.", - "version": "4.7.0", + "version": "4.10.2", "license": "MIT", "readmeFilename": "README.md", "type": "module", diff --git a/src/README.md b/src/README.md index 5ba1109..1410ec8 100644 --- a/src/README.md +++ b/src/README.md @@ -78,6 +78,9 @@ Audio must still be started by user gestures when browsers require it. - Spawn coordinates along an arc. - Radial collision and area-exit checks. - Object cloning. + +[string-tile-map.ts](string-tile-map.ts) parses text-authored tile maps and +provides cell lookup helpers for grid, board, and room-style games. - Random colors. - Event binding and unbinding. @@ -190,7 +193,7 @@ Use it for: Games keep their concrete option schema, migrations, and validation rules. The module is exported from the package root, and the -`Engine/Systems/Player Data/User Options` Storybook story shows a small +`Engine/Player Data/User Options` Storybook story shows a small settings store. ## ๐Ÿงฐ Runtime Utilities @@ -216,7 +219,7 @@ Use it for: - Achievement definitions, unlock state, progress counters, and status lists. The module has no browser dependency and is exported from the package root. The -`Engine/Systems/Achievements/Achievements` Storybook story shows progress +`Engine/Achievements/Achievements` Storybook story shows progress counters, direct unlocks, status lists, and immutable state updates. ## ๐Ÿ† Achievement Notifications @@ -252,7 +255,7 @@ Use it for: Backends can import `arcade-engine/high-scores` for receipt and submission validation helpers while keeping route handling, score storage, used-receipt -updates, and rate limits app-owned. See the `Engine/Systems/Player Data/High Scores` +updates, and rate limits app-owned. See the `Engine/Player Data/High Scores` Storybook story for local leaderboards, default-score merging, thresholds, integrity validation, and plausibility feedback. @@ -331,6 +334,23 @@ helpers used by the visual demos: `fillCanvasWithTrail` accepts normal CSS colors. The color conversion helpers expect 3 or 6 digit hex strings. +## ๐Ÿ’ก Ray Tracing + +[ray-tracing.ts](ray-tracing.ts) contains renderer-agnostic 2D visibility +helpers: + +- Rectangle and bounds polygon creation. +- Polygon-to-segment conversion. +- Nearest ray/segment intersection. +- Sorted visibility polygon tracing from a light or viewpoint. +- Capped diffuse bounce layer tracing for simple indirect-light demos. +- Optional surface colors for material-tinted bounce layers. + +Use these helpers for canvas lighting, vision cones, line-of-sight previews, +fog-of-war shapes, or stealth visibility. The helpers return geometry only; +games still own gradients, color blending, shadow styling, and interaction. +Bounce requests are currently capped to `0..3` layers. + ## ๐Ÿ•น๏ธ 2.5D Projection [arcade-3d.ts](arcade-3d.ts) contains math helpers for pseudo-3D and isometric diff --git a/src/__tests__/coverage.test.ts b/src/__tests__/coverage.test.ts index e599938..b3e5361 100644 --- a/src/__tests__/coverage.test.ts +++ b/src/__tests__/coverage.test.ts @@ -737,6 +737,8 @@ describe("coverage-focused engine branches", () => { "createProceduralStarfield", "createRagdoll2D", "createRagdoll3D", + "createRayTracingBoundsPolygon", + "createRayTracingRectangle", "createRuntimeLogger", "createScreenDropletsEffectDefinition", "createScreenFireEffectDefinition", @@ -774,6 +776,8 @@ describe("coverage-focused engine branches", () => { "environmentUnderwaterEffectId", "exitInstalledApp", "fillCanvasWithTrail", + "findStringTileMapCell", + "findStringTileMapCells", "formatZoomPercent", "getAchievementStatuses", "getAnimatedSpriteFrame", @@ -804,6 +808,8 @@ describe("coverage-focused engine branches", () => { "getNextRuntimeLogLevel", "getPerspectiveScale", "getPlayerActionState", + "getRayTracingPolygonSegments", + "getRayTracingSegments", "getScaledViewportLimit", "getSideScrollerActorPosition", "getSideScrollerJumpY", @@ -813,6 +819,9 @@ describe("coverage-focused engine branches", () => { "getSpriteFrameIndex", "getSpriteSheetFrame", "getSteppedZoomPercent", + "getStringTileMapCellFromCenteredPoint", + "getStringTileMapCenteredPoint", + "getStringTileMapTile", "getVectorDistance", "getViewportAreaScale", "getViewportPaddedRadius", @@ -840,6 +849,7 @@ describe("coverage-focused engine branches", () => { "normalizeUserOptions", "normalizeVector", "parseHexColor", + "parseStringTileMap", "projectIsometricPoint", "projectPerspectivePoint", "removeScoreStorageKeys", @@ -860,6 +870,9 @@ describe("coverage-focused engine branches", () => { "stepExplosionBlocks", "stepRagdoll2D", "stepRagdoll3D", + "traceLightBounces", + "traceRay", + "traceVisibilityPolygon", "unlockAchievement", "userOptionsChangedEventName", "validateHighScoreIntegrity", diff --git a/src/__tests__/ray-tracing.test.ts b/src/__tests__/ray-tracing.test.ts new file mode 100644 index 0000000..038c8d0 --- /dev/null +++ b/src/__tests__/ray-tracing.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + createRayTracingRectangle, + getRayTracingSegments, + traceLightBounces, + traceRay, + traceVisibilityPolygon, +} from "../ray-tracing.js"; + +describe("ray tracing helpers", () => { + it("creates clockwise rectangle polygons", () => { + expect(createRayTracingRectangle(10, 20, 30, 40)).toEqual([ + { x: 10, y: 20 }, + { x: 40, y: 20 }, + { x: 40, y: 60 }, + { x: 10, y: 60 }, + ]); + }); + + it("finds the nearest segment hit for a ray", () => { + const hit = traceRay( + { x: 10, y: 10 }, + 0, + getRayTracingSegments( + { height: 100, width: 100 }, + [{ polygon: createRayTracingRectangle(40, 0, 10, 100), surfaceColor: "#884422" }] + ) + ); + + expect(hit?.x).toBeCloseTo(40); + expect(hit?.y).toBeCloseTo(10); + expect(hit?.distance).toBeCloseTo(30); + expect(hit?.segment.from.x).toBe(40); + expect(hit?.segment.surfaceColor).toBe("#884422"); + }); + + it("returns sorted visibility hits clipped by bounds and occluders", () => { + const hits = traceVisibilityPolygon( + { x: 20, y: 50 }, + { height: 100, width: 100 }, + [createRayTracingRectangle(45, 35, 20, 30)] + ); + const rightMostHit = hits.reduce((rightMost, hit) => + hit.x > rightMost.x ? hit : rightMost + ); + + expect(hits.length).toBeGreaterThan(4); + expect(hits).toEqual([...hits].sort((a, b) => a.angle - b.angle)); + expect(rightMostHit.x).toBeLessThanOrEqual(100); + expect(hits.some((hit) => hit.x >= 45 && hit.x <= 65 && hit.y >= 35 && hit.y <= 65)).toBe( + true + ); + }); + + it("traces direct light and caps bounce layers at three", () => { + const layers = traceLightBounces( + { x: 20, y: 50 }, + { height: 100, width: 100 }, + [{ polygon: createRayTracingRectangle(45, 35, 20, 30), surfaceColor: "#804020" }], + { bounces: 9, lightColor: "#ffffff", maxOriginsPerBounce: 2, surfaceColorMix: 0.5 } + ); + + expect(layers[0]?.level).toBe(0); + expect(layers[0]?.color).toBe("#ffffff"); + expect(layers[0]?.intensity).toBe(1); + expect(Math.max(...layers.map((layer) => layer.level))).toBe(3); + expect(layers.some((layer) => layer.level === 1 && layer.intensity < 1)).toBe(true); + expect(layers.some((layer) => layer.level === 1 && layer.color !== "#ffffff")).toBe(true); + }); +}); diff --git a/src/__tests__/string-tile-map.test.ts b/src/__tests__/string-tile-map.test.ts new file mode 100644 index 0000000..eb4cdbd --- /dev/null +++ b/src/__tests__/string-tile-map.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + findStringTileMapCell, + findStringTileMapCells, + getStringTileMapCellFromCenteredPoint, + getStringTileMapCenteredPoint, + getStringTileMapTile, + parseStringTileMap, +} from "../string-tile-map.js"; + +describe("string tile maps", () => { + it("parses and pads multiline string maps", () => { + const map = parseStringTileMap("##\n#S#"); + + expect(map.width).toBe(3); + expect(map.height).toBe(2); + expect(map.rows).toEqual([ + ["#", "#", " "], + ["#", "S", "#"], + ]); + }); + + it("normalizes tiles", () => { + const map = parseStringTileMap<"#" | " ">("#!", { + emptyTile: " ", + normalizeTile: (tile) => (tile === "#" ? "#" : " "), + }); + + expect(map.rows[0]).toEqual(["#", " "]); + }); + + it("finds cells and converts centered coordinates", () => { + const map = parseStringTileMap("###\n#S#\n###"); + const spawn = findStringTileMapCell(map, "S"); + + expect(spawn).toEqual({ column: 1, row: 1, tile: "S", x: 0, z: 0 }); + expect(findStringTileMapCells(map, "#")).toHaveLength(8); + expect(getStringTileMapCenteredPoint(map, 2, 0)).toEqual({ x: 1, z: -1 }); + expect(getStringTileMapCellFromCenteredPoint(map, 0, 0)).toEqual(spawn); + expect(getStringTileMapTile(map, 2, 2)).toBe("#"); + }); +}); diff --git a/src/index.ts b/src/index.ts index c0cbfde..a97da1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,19 @@ export { projectPerspectivePoint, wrapDepth, } from "./arcade-3d.js"; +export { + findStringTileMapCell, + findStringTileMapCells, + getStringTileMapCellFromCenteredPoint, + getStringTileMapCenteredPoint, + getStringTileMapTile, + parseStringTileMap, +} from "./string-tile-map.js"; +export type { + ParseStringTileMapOptions, + StringTileMap, + StringTileMapCell, +} from "./string-tile-map.js"; export { AtmosphericAshEmberEffect, AtmosphericRainEffect, @@ -127,6 +140,15 @@ export { parseHexColor, shadeHexColor, } from "./canvas-rendering.js"; +export { + createRayTracingBoundsPolygon, + createRayTracingRectangle, + getRayTracingPolygonSegments, + getRayTracingSegments, + traceLightBounces, + traceRay, + traceVisibilityPolygon, +} from "./ray-tracing.js"; export { defaultCustomDisplayFilterSettings, defaultDisplayFilterMode, @@ -439,6 +461,16 @@ export type { SpatialAudioMixOptions, } from "./spatial-audio.js"; export type { RgbColor } from "./canvas-rendering.js"; +export type { + RayTracingBounce, + RayTracingBounceOptions, + RayTracingBounds, + RayTracingHit, + RayTracingPoint, + RayTracingPolygon, + RayTracingSegment, + RayTracingSurface, +} from "./ray-tracing.js"; export type { GravityOptions, PhysicsBody2D, diff --git a/src/ray-tracing.ts b/src/ray-tracing.ts new file mode 100644 index 0000000..4b79221 --- /dev/null +++ b/src/ray-tracing.ts @@ -0,0 +1,354 @@ +export type RayTracingPoint = { + x: number; + y: number; +}; + +export type RayTracingBounds = { + height: number; + surfaceColor?: string; + width: number; + x?: number; + y?: number; +}; + +export type RayTracingSegment = { + from: RayTracingPoint; + surfaceColor?: string; + to: RayTracingPoint; +}; + +export type RayTracingPolygon = readonly RayTracingPoint[]; + +export type RayTracingSurface = { + polygon: RayTracingPolygon; + surfaceColor?: string; +}; + +export type RayTracingHit = RayTracingPoint & { + angle: number; + distance: number; + segment: RayTracingSegment; +}; + +export type RayTracingBounce = { + color?: string; + hits: RayTracingHit[]; + intensity: number; + level: number; + origin: RayTracingPoint; +}; + +export type RayTracingBounceOptions = { + attenuation?: number; + bounces?: number; + lightColor?: string; + maxOriginsPerBounce?: number; + surfaceColorMix?: number; + surfaceOffset?: number; +}; + +const rayAngleEpsilon = 0.00001; +const pointComparisonEpsilon = 0.0001; +const maxRayTracingBounces = 3; +const hexColorPattern = /^#?([\da-f]{3}|[\da-f]{6})$/i; + +const normalizeHexColor = (color: string): string | undefined => { + const match = hexColorPattern.exec(color.trim()); + const value = match?.[1]; + + if (!value) { + return undefined; + } + + if (value.length === 3) { + return `#${value + .split("") + .map((part) => part + part) + .join("")}`; + } + + return `#${value}`; +}; + +const parseRayTracingHexColor = (color: string): [number, number, number] | undefined => { + const normalized = normalizeHexColor(color); + + if (!normalized) { + return undefined; + } + + const value = Number.parseInt(normalized.slice(1), 16); + + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +}; + +const toRayTracingHexColor = ([red, green, blue]: [number, number, number]): string => + `#${[red, green, blue] + .map((value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0")) + .join("")}`; + +const mixRayTracingColors = ( + baseColor: string | undefined, + surfaceColor: string | undefined, + surfaceMix: number +): string | undefined => { + if (!baseColor || !surfaceColor) { + return baseColor ?? surfaceColor; + } + + const base = parseRayTracingHexColor(baseColor); + const surface = parseRayTracingHexColor(surfaceColor); + + if (!base || !surface) { + return baseColor; + } + + const mix = Math.max(0, Math.min(1, surfaceMix)); + + return toRayTracingHexColor([ + base[0] * (1 - mix) + surface[0] * mix, + base[1] * (1 - mix) + surface[1] * mix, + base[2] * (1 - mix) + surface[2] * mix, + ]); +}; + +export const createRayTracingRectangle = ( + x: number, + y: number, + width: number, + height: number +): RayTracingPolygon => [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height }, +]; + +export const createRayTracingBoundsPolygon = ( + bounds: RayTracingBounds +): RayTracingPolygon => { + const x = bounds.x ?? 0; + const y = bounds.y ?? 0; + + return createRayTracingRectangle(x, y, bounds.width, bounds.height); +}; + +export const getRayTracingPolygonSegments = ( + polygon: RayTracingPolygon, + surfaceColor?: string +): RayTracingSegment[] => + polygon.map((point, index) => ({ + from: point, + surfaceColor, + to: polygon[(index + 1) % polygon.length] ?? point, + })); + +const isRayTracingSurface = ( + occluder: RayTracingPolygon | RayTracingSurface +): occluder is RayTracingSurface => !Array.isArray(occluder); + +const getRayTracingOccluderPolygon = ( + occluder: RayTracingPolygon | RayTracingSurface +): RayTracingPolygon => isRayTracingSurface(occluder) ? occluder.polygon : occluder; + +const getRayTracingOccluderSurfaceColor = ( + occluder: RayTracingPolygon | RayTracingSurface +): string | undefined => isRayTracingSurface(occluder) ? occluder.surfaceColor : undefined; + +export const getRayTracingSegments = ( + bounds: RayTracingBounds, + occluders: readonly (RayTracingPolygon | RayTracingSurface)[] = [] +): RayTracingSegment[] => [ + ...getRayTracingPolygonSegments(createRayTracingBoundsPolygon(bounds), bounds.surfaceColor), + ...occluders.flatMap((occluder) => + getRayTracingPolygonSegments( + getRayTracingOccluderPolygon(occluder), + getRayTracingOccluderSurfaceColor(occluder) + ) + ), +]; + +export const traceRay = ( + origin: RayTracingPoint, + angle: number, + segments: readonly RayTracingSegment[] +): RayTracingHit | undefined => { + const rayDirection = { + x: Math.cos(angle), + y: Math.sin(angle), + }; + let nearest: RayTracingHit | undefined; + + segments.forEach((segment) => { + const segmentDirection = { + x: segment.to.x - segment.from.x, + y: segment.to.y - segment.from.y, + }; + const determinant = + rayDirection.x * segmentDirection.y - rayDirection.y * segmentDirection.x; + + if (Math.abs(determinant) < Number.EPSILON) { + return; + } + + const delta = { + x: segment.from.x - origin.x, + y: segment.from.y - origin.y, + }; + const rayDistance = + (delta.x * segmentDirection.y - delta.y * segmentDirection.x) / determinant; + const segmentDistance = + (delta.x * rayDirection.y - delta.y * rayDirection.x) / determinant; + + if (rayDistance < 0 || segmentDistance < 0 || segmentDistance > 1) { + return; + } + + if (!nearest || rayDistance < nearest.distance) { + nearest = { + angle, + distance: rayDistance, + segment, + x: origin.x + rayDirection.x * rayDistance, + y: origin.y + rayDirection.y * rayDistance, + }; + } + }); + + return nearest; +}; + +export const traceVisibilityPolygon = ( + origin: RayTracingPoint, + bounds: RayTracingBounds, + occluders: readonly (RayTracingPolygon | RayTracingSurface)[] = [] +): RayTracingHit[] => { + const vertices = [ + ...createRayTracingBoundsPolygon(bounds), + ...occluders.flatMap((occluder) => [...getRayTracingOccluderPolygon(occluder)]), + ]; + const segments = getRayTracingSegments(bounds, occluders); + const angles = vertices.flatMap((vertex) => { + const angle = Math.atan2(vertex.y - origin.y, vertex.x - origin.x); + + return [angle - rayAngleEpsilon, angle, angle + rayAngleEpsilon]; + }); + const hits = angles + .map((angle) => traceRay(origin, angle, segments)) + .filter((hit): hit is RayTracingHit => Boolean(hit)) + .sort((a, b) => a.angle - b.angle); + const uniqueHits: RayTracingHit[] = []; + + hits.forEach((hit) => { + const previous = uniqueHits[uniqueHits.length - 1]; + + if ( + previous && + Math.abs(previous.x - hit.x) < pointComparisonEpsilon && + Math.abs(previous.y - hit.y) < pointComparisonEpsilon + ) { + return; + } + + uniqueHits.push(hit); + }); + + return uniqueHits; +}; + +const clampBounceCount = (bounces: number): number => + Math.max(0, Math.min(maxRayTracingBounces, Math.floor(bounces))); + +const getBounceNormal = ( + origin: RayTracingPoint, + hit: RayTracingHit +): RayTracingPoint => { + const segmentDirection = { + x: hit.segment.to.x - hit.segment.from.x, + y: hit.segment.to.y - hit.segment.from.y, + }; + const length = Math.hypot(segmentDirection.x, segmentDirection.y) || 1; + const normal = { + x: -segmentDirection.y / length, + y: segmentDirection.x / length, + }; + const toOrigin = { + x: origin.x - hit.x, + y: origin.y - hit.y, + }; + const dot = normal.x * toOrigin.x + normal.y * toOrigin.y; + + return dot >= 0 ? normal : { x: -normal.x, y: -normal.y }; +}; + +const getBounceOrigins = ( + origin: RayTracingPoint, + color: string | undefined, + hits: readonly RayTracingHit[], + maxOrigins: number, + surfaceOffset: number, + surfaceColorMix: number +): Array => { + const step = Math.max(1, Math.floor(hits.length / Math.max(1, maxOrigins))); + + return hits + .filter((_, index) => index % step === 0) + .slice(0, maxOrigins) + .map((hit) => { + const normal = getBounceNormal(origin, hit); + + return { + color: mixRayTracingColors(color, hit.segment.surfaceColor, surfaceColorMix), + x: hit.x + normal.x * surfaceOffset, + y: hit.y + normal.y * surfaceOffset, + }; + }); +}; + +export const traceLightBounces = ( + origin: RayTracingPoint, + bounds: RayTracingBounds, + occluders: readonly (RayTracingPolygon | RayTracingSurface)[] = [], + options: RayTracingBounceOptions = {} +): RayTracingBounce[] => { + const bounceCount = clampBounceCount(options.bounces ?? 0); + const attenuation = options.attenuation ?? 0.32; + const maxOriginsPerBounce = Math.max(1, Math.floor(options.maxOriginsPerBounce ?? 8)); + const surfaceOffset = options.surfaceOffset ?? 0.75; + const surfaceColorMix = options.surfaceColorMix ?? 0.35; + const layers: RayTracingBounce[] = [ + { + color: options.lightColor, + hits: traceVisibilityPolygon(origin, bounds, occluders), + intensity: 1, + level: 0, + origin, + }, + ]; + + for (let level = 1; level <= bounceCount; level += 1) { + const previous = layers.filter((layer) => layer.level === level - 1); + const nextOrigins = previous.flatMap((layer) => + getBounceOrigins( + layer.origin, + layer.color, + layer.hits, + maxOriginsPerBounce, + surfaceOffset, + surfaceColorMix + ) + ); + + nextOrigins.forEach((bounceOrigin) => { + layers.push({ + color: bounceOrigin.color, + hits: traceVisibilityPolygon(bounceOrigin, bounds, occluders), + intensity: Math.pow(attenuation, level), + level, + origin: bounceOrigin, + }); + }); + } + + return layers; +}; diff --git a/src/stories/GameArena.stories.ts b/src/stories/GameArena.stories.ts index 93996c4..efa5171 100644 --- a/src/stories/GameArena.stories.ts +++ b/src/stories/GameArena.stories.ts @@ -337,7 +337,15 @@ export const PerspectiveArena: Story = { fontFamily: "monospace", }); const ticker = new Ticker(); - const pointer = { x: 0, y: 0 }; + const pointer = { + active: false, + baseX: 0, + baseY: 0, + dragStartX: 0, + dragStartY: 0, + x: 0, + y: 0, + }; let lastTime = performance.now(); let elapsedSeconds = 0; let fpsAge = 0; @@ -362,24 +370,49 @@ export const PerspectiveArena: Story = { values.append(modeValue, pointerValue, fpsValue); controls.append( createButton("Reset View", () => { + pointer.baseX = 0; + pointer.baseY = 0; + pointer.dragStartX = 0; + pointer.dragStartY = 0; pointer.x = 0; pointer.y = 0; }) ); - const updatePointer = (event: PointerEvent): void => { + const getNormalizedPointer = (event: PointerEvent): { x: number; y: number } => { const bounds = canvas.getBoundingClientRect(); - pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; - pointer.y = ((event.clientY - bounds.top) / bounds.height) * 2 - 1; + return { + x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1, + y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1, + }; + }; + const updatePointer = (event: PointerEvent): void => { + const normalizedPointer = getNormalizedPointer(event); + + pointer.x = pointer.baseX + normalizedPointer.x - pointer.dragStartX; + pointer.y = pointer.baseY + normalizedPointer.y - pointer.dragStartY; }; const handlePointerDown = (event: PointerEvent): void => { - updatePointer(event); + const normalizedPointer = getNormalizedPointer(event); + + pointer.active = true; + pointer.baseX = pointer.x; + pointer.baseY = pointer.y; + pointer.dragStartX = normalizedPointer.x; + pointer.dragStartY = normalizedPointer.y; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); }; - const handlePointerMove = (event: PointerEvent): void => updatePointer(event); + const handlePointerMove = (event: PointerEvent): void => { + if (!pointer.active) { + return; + } + + updatePointer(event); + }; const handlePointerUp = (event: PointerEvent): void => { + pointer.active = false; canvas.style.cursor = "grab"; if (canvas.hasPointerCapture(event.pointerId)) { diff --git a/src/stories/README.md b/src/stories/README.md index f968ec6..3950667 100644 --- a/src/stories/README.md +++ b/src/stories/README.md @@ -62,15 +62,21 @@ viewport scaling, padded radii, movement vectors, and a depth variant. [Helpers.stories.ts](Helpers.stories.ts) is the broad helper overview. More focused helper stories live in [helpers/README.md](helpers/README.md). -### Systems +### Input, Data, Rendering, Effects, And Physics -[systems/Systems.stories.ts](systems/Systems.stories.ts) documents newer -system-level helpers for input actions, local multiplayer, sprite animation, -follow cameras, user options, display filters, achievements, achievement -notifications, high scores, procedural background stars, player screen effects, -environment screen effects, atmospheric effects, gravity, 2D/3D ragdolls, and -spatial-audio math. -See [systems/README.md](systems/README.md). +The shared demo implementations in [systems/](systems/README.md) are grouped in +the Storybook sidebar by the feature they demonstrate: + +- `Engine/Input` for input actions and local multiplayer. +- `Engine/Player Data` for user options and high scores. +- `Engine/Achievements` for achievement progress and notifications. +- `Engine/Rendering` for display filters, sprite animation, procedural stars, + camera helpers, and ray-traced apartment lighting. +- `Engine/Effects` for player, environment, atmospheric, and combo effects. +- `Engine/Physics` for gravity and 2D/3D ragdolls. +- `Engine/Audio/Spatial Audio` for spatial-audio math. + +See [systems/README.md](systems/README.md) for the shared implementation notes. Screen-effect and atmospheric demos share [fps-demo-scene.ts](fps-demo-scene.ts), a low-resolution Canvas 2D first-person corridor renderer with a fixed @@ -99,7 +105,7 @@ styles that can be built from the engine's 2.5D and canvas helpers: - Starfighter run. - Isometric dungeon room. - Hyperspace gate. -- First-person player. +- First-person player using the shared FPS corridor scene. - 2D side scroller with obstacles, ladders, platforms, and dummy enemies. - 2.5D belt side scroller with depth-scaled obstacles and dummy enemies. diff --git a/src/stories/Showcase.stories.ts b/src/stories/Showcase.stories.ts index 98ce02c..a7e8851 100644 --- a/src/stories/Showcase.stories.ts +++ b/src/stories/Showcase.stories.ts @@ -41,7 +41,7 @@ export const AnimatedArcadeLoop: Story = { const shell = createDemoShell("Arcade Engine Showcase"); const grid = document.createElement("div"); const stagePanel = createPanel("Playable Surface"); - const systemsPanel = createPanel("Live Systems"); + const systemsPanel = createPanel("Live Features"); const stage = document.createElement("div"); const values = document.createElement("div"); const controls = document.createElement("div"); @@ -247,7 +247,7 @@ export const AnimatedArcadeLoopDepth: Story = { const shell = createDemoShell("Arcade Engine Showcase: 2.5D / 3D"); const grid = document.createElement("div"); const stagePanel = createPanel("Depth Surface"); - const systemsPanel = createPanel("Live Systems"); + const systemsPanel = createPanel("Live Features"); const stage = document.createElement("div"); const values = document.createElement("div"); const controls = document.createElement("div"); diff --git a/src/stories/arcade-camera-demos.ts b/src/stories/arcade-camera-demos.ts index 46dc643..77e78b2 100644 --- a/src/stories/arcade-camera-demos.ts +++ b/src/stories/arcade-camera-demos.ts @@ -5,16 +5,21 @@ import { drawCanvasLine, drawCanvasPolygon, fillCanvasWithTrail, + findStringTileMapCell, + findStringTileMapCells, getDepthProgress, - getFirstPersonCamera, getIsometricTileCorners, - getIsometricWallSide, getLoopedScrollerPosition, getLoopedDepth, + getStringTileMapCellFromCenteredPoint, + getStringTileMapCenteredPoint, + getStringTileMapTile, projectIsometricPoint, projectPerspectivePoint, + parseStringTileMap, getSideScrollerActorPosition, getSideScrollerJumpY, + type StringTileMap, Ticker, } from "../index.js"; import { @@ -25,6 +30,7 @@ import { onRemove, setValue, } from "./story-utils.js"; +import { drawFpsDemoScene } from "./fps-demo-scene.js"; type Arcade3DStoryArgs = { accentColor: string; @@ -40,19 +46,92 @@ type Story = StoryObj; type PointerState = { active: boolean; + baseX: number; + baseY: number; + dragStartX: number; + dragStartY: number; x: number; y: number; + zoom: number; +}; + +type KeyboardState = { + chestOpen: boolean; + doorOpenProgress: Map; + exitReached: boolean; + facingX: number; + facingZ: number; + interactionLabel: string; + playerX: number; + playerZ: number; + pressed: Set; + stairsReached: boolean; }; type SceneContext = { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; delta: number; + dungeonMap: DungeonMapState; elapsed: number; frame: number; + keyboard: KeyboardState; pointer: PointerState; }; +type IsometricDungeonTile = "#" | " " | "." | "_" | "C" | "D" | "P" | "S" | "d" | "o" | "r" | "u" | "w"; +type IsometricDungeonTileRole = "blocked" | "floor" | "interactable" | "light" | "prop" | "spawn" | "void"; + +type StoryShellOptions = { + enableArrowMovement?: boolean; + mapEditor?: { + initialText: string; + }; + enableWheelZoom?: boolean; + updateStats?: (scene: SceneContext, args: Arcade3DStoryArgs) => Array; +}; + +type DungeonMapState = StringTileMap & { + spawnX: number; + spawnZ: number; +}; + +type IsometricDungeonCamera = { + focusX: number; + focusZ: number; +}; + +type IsometricDungeonDirection = "east" | "north" | "south" | "west"; + +type DungeonMapEditorSurface = { + editor: HTMLTextAreaElement; + element: HTMLElement; + legend: HTMLElement; + resetLegendPosition: () => void; +}; + +type DungeonMapLegendSurface = { + element: HTMLElement; + resetPosition: () => void; +}; + +type DungeonProjectedTile = { + center: ReturnType; + corners: ReturnType; + facing: IsometricDungeonDirection; + gridX: number; + gridZ: number; + tileKind: IsometricDungeonTile; + xIndex: number; + zIndex: number; +}; + +type DungeonRenderable = { + depth: number; + draw: () => void; + order: number; +}; + const argTypes: Story["argTypes"] = { accentColor: { name: "Accent color", control: "color" }, backgroundColor: { name: "Background color", control: "color" }, @@ -63,6 +142,16 @@ const argTypes: Story["argTypes"] = { trailOpacity: { name: "Trail opacity", control: { type: "range", min: 0, max: 0.45, step: 0.01 } }, }; +const isometricDungeonArgTypes: Story["argTypes"] = { + ...argTypes, + depth: { + table: { disable: true }, + }, + objectCount: { + table: { disable: true }, + }, +}; + const defaultArgs = { accentColor: "#4fd1c5", backgroundColor: "#05070a", @@ -73,11 +162,128 @@ const defaultArgs = { trailOpacity: 0.16, } satisfies Arcade3DStoryArgs; +const defaultIsometricDungeonMapText = ` +################# +# r o # +# www # +# P P # +# wwwww # +# wwwww # +# wwwww ###### +# wwwww # o # +# wwwww rDr C # +# wwwww # o # +# wwwww ###### +# wwwww # +# wwwww # +# P P # +# o o # +# # +########D######## +### o o ### +# www # +# P P # +# # +# S # +# . # +# # +# # +# # +# # +# # +# u d # +# C r # +# P o P # +# # +################# +`.trim(); + +const isometricDungeonTiles = { + " ": { label: "floor", role: "floor", walkable: true }, + "#": { label: "stone wall", role: "blocked", walkable: false }, + ".": { label: "floor marker", role: "floor", walkable: true }, + _: { label: "empty", role: "void", walkable: false }, + C: { label: "chest", role: "interactable", walkable: true }, + D: { label: "door", role: "interactable", walkable: true }, + P: { label: "pillar", role: "prop", walkable: false }, + S: { label: "player spawn", role: "spawn", walkable: true }, + d: { label: "stairs down", role: "interactable", walkable: true }, + o: { label: "light source", role: "light", walkable: true }, + r: { label: "rubble", role: "prop", walkable: false }, + u: { label: "stairs up", role: "interactable", walkable: true }, + w: { label: "water", role: "floor", walkable: true }, +} satisfies Record< + IsometricDungeonTile, + { + label: string; + role: IsometricDungeonTileRole; + walkable: boolean; + } +>; + +let isometricDungeonCanvas: HTMLCanvasElement | undefined; + +const parseIsometricDungeonMap = (text: string): DungeonMapState => { + const map = parseStringTileMap(text, { + emptyTile: "_", + normalizeTile: parseIsometricDungeonTile, + }); + const spawn = findStringTileMapCell(map, "S"); + const fallbackSpawn = getStringTileMapCenteredPoint( + map, + Math.floor(map.width / 2), + Math.floor(map.height / 2) + ); + + return { + ...map, + spawnX: (spawn?.x ?? fallbackSpawn.x) + 0.5, + spawnZ: (spawn?.z ?? fallbackSpawn.z) + 0.5, + }; +}; + +const parseIsometricDungeonTile = (tile: string): IsometricDungeonTile => { + if (tile in isometricDungeonTiles) { + return tile as IsometricDungeonTile; + } + + return " "; +}; + +const getIsometricDungeonGridX = (column: number, width: number): number => + column - Math.floor(width / 2); + +const getIsometricDungeonGridZ = (row: number, height: number): number => + row - Math.floor(height / 2); + +const getIsometricDungeonColumn = (x: number, width: number): number => + Math.floor(x + Math.floor(width / 2)); + +const getIsometricDungeonRow = (z: number, height: number): number => + Math.floor(z + Math.floor(height / 2)); + +const resetIsometricDungeonPlayer = ( + keyboard: KeyboardState, + dungeonMap: DungeonMapState +): void => { + keyboard.chestOpen = false; + keyboard.doorOpenProgress.clear(); + keyboard.exitReached = false; + keyboard.facingX = 1; + keyboard.facingZ = 0; + keyboard.interactionLabel = "none"; + keyboard.playerX = dungeonMap.spawnX; + keyboard.playerZ = dungeonMap.spawnZ; + keyboard.pressed.clear(); + keyboard.stairsReached = false; +}; + const createStoryShell = ( title: string, args: Arcade3DStoryArgs, renderScene: (scene: SceneContext, args: Arcade3DStoryArgs) => void, - stats: Array<[string, string | number]> + stats: Array<[string, string | number]>, + options: StoryShellOptions = {} ): HTMLElement => { const shell = createDemoShell(title); const grid = document.createElement("div"); @@ -89,7 +295,29 @@ const createStoryShell = ( const valueItems = stats.map(([label, value]) => createValue(label, value)); const fpsValue = createValue("fps", "0"); const ticker = new Ticker(); - const pointer: PointerState = { active: false, x: 0, y: 0 }; + let dungeonMap = parseIsometricDungeonMap(options.mapEditor?.initialText ?? defaultIsometricDungeonMapText); + const keyboard: KeyboardState = { + chestOpen: false, + doorOpenProgress: new Map(), + exitReached: false, + facingX: 1, + facingZ: 0, + interactionLabel: "none", + playerX: dungeonMap.spawnX, + playerZ: dungeonMap.spawnZ, + pressed: new Set(), + stairsReached: false, + }; + const pointer: PointerState = { + active: false, + baseX: 0, + baseY: 0, + dragStartX: 0, + dragStartY: 0, + x: 0, + y: 0, + zoom: 1, + }; let frame = 0; let lastTime = performance.now(); let fpsAge = 0; @@ -103,12 +331,38 @@ const createStoryShell = ( canvas.height = 420; canvas.style.cursor = "grab"; canvas.style.touchAction = "none"; + canvas.tabIndex = 0; stage.appendChild(canvas); values.append(...valueItems, fpsValue); + const mapEditorSurface = options.mapEditor + ? createIsometricDungeonMapEditor(options.mapEditor.initialText) + : undefined; + const mapEditor = mapEditorSurface?.editor; + const telemetryHeading = telemetryPanel.querySelector("h2"); + + mapEditor?.addEventListener("input", () => { + dungeonMap = parseIsometricDungeonMap(mapEditor.value); + resetIsometricDungeonPlayer(keyboard, dungeonMap); + }); + + if (mapEditorSurface) { + telemetryPanel.style.position = "relative"; + + if (telemetryHeading instanceof HTMLElement) { + telemetryHeading.style.marginRight = "42px"; + } + } + scenePanel.appendChild(stage); - telemetryPanel.appendChild(values); + telemetryPanel.append( + ...(mapEditorSurface ? [mapEditorSurface.legend, values, mapEditorSurface.element] : [values]) + ); grid.append(scenePanel, telemetryPanel); shell.appendChild(grid); + mapEditorSurface?.resetLegendPosition(); + requestAnimationFrame(() => { + mapEditorSurface?.resetLegendPosition(); + }); const context = canvas.getContext("2d"); @@ -116,340 +370,2258 @@ const createStoryShell = ( return shell; } - const updatePointer = (event: PointerEvent): void => { + const getNormalizedPointer = (event: PointerEvent): { x: number; y: number } => { const bounds = canvas.getBoundingClientRect(); - pointer.active = true; - pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; - pointer.y = ((event.clientY - bounds.top) / bounds.height) * 2 - 1; + return { + x: ((event.clientX - bounds.left) / bounds.width) * 2 - 1, + y: ((event.clientY - bounds.top) / bounds.height) * 2 - 1, + }; + }; + + const updatePointer = (event: PointerEvent): void => { + const normalizedPointer = getNormalizedPointer(event); + + pointer.x = pointer.baseX + normalizedPointer.x - pointer.dragStartX; + pointer.y = pointer.baseY + normalizedPointer.y - pointer.dragStartY; }; const handlePointerDown = (event: PointerEvent): void => { - updatePointer(event); + const normalizedPointer = getNormalizedPointer(event); + + canvas.focus(); + pointer.active = true; + pointer.baseX = pointer.x; + pointer.baseY = pointer.y; + pointer.dragStartX = normalizedPointer.x; + pointer.dragStartY = normalizedPointer.y; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); }; - const handlePointerMove = (event: PointerEvent): void => updatePointer(event); + const handlePointerMove = (event: PointerEvent): void => { + if (!pointer.active) { + return; + } + + updatePointer(event); + }; const handlePointerUp = (event: PointerEvent): void => { + pointer.active = false; canvas.style.cursor = "grab"; - if (canvas.hasPointerCapture(event.pointerId)) { - canvas.releasePointerCapture(event.pointerId); - } - }; + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + }; + + const handlePointerLeave = (): void => { + if (!pointer.active) { + canvas.style.cursor = "grab"; + } + }; + + const handleWheel = (event: WheelEvent): void => { + if (!options.enableWheelZoom) { + return; + } + + event.preventDefault(); + const zoomDelta = event.deltaY > 0 ? -0.08 : 0.08; + + pointer.zoom = Math.max(0.65, Math.min(2.2, pointer.zoom + zoomDelta)); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (!options.enableArrowMovement || isTextEditingTarget(event.target) || !isArrowKey(event.key)) { + return; + } + + event.preventDefault(); + keyboard.pressed.add(event.key); + }; + + const handleKeyUp = (event: KeyboardEvent): void => { + if (!options.enableArrowMovement || isTextEditingTarget(event.target) || !isArrowKey(event.key)) { + return; + } + + event.preventDefault(); + keyboard.pressed.delete(event.key); + }; + + canvas.addEventListener("pointerdown", handlePointerDown); + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", handlePointerUp); + canvas.addEventListener("pointercancel", handlePointerUp); + canvas.addEventListener("pointerleave", handlePointerLeave); + canvas.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + const render = (): void => { + const now = performance.now(); + const delta = Math.min(0.05, (now - lastTime) / 1000); + + frame += 1; + fpsAge += delta; + fpsFrames += 1; + lastTime = now; + + if (fpsAge >= 0.5) { + setValue(fpsValue, Math.round(fpsFrames / fpsAge)); + fpsAge = 0; + fpsFrames = 0; + } + + const scene = { + canvas, + context, + delta, + dungeonMap, + elapsed: now / 1000, + frame, + keyboard, + pointer, + }; + + renderScene(scene, args); + + options.updateStats?.(scene, args).forEach((value, index) => { + const item = valueItems[index]; + + if (item) { + setValue(item, value); + } + }); + }; + + ticker.addSchedule(render, 1); + ticker.start(); + onRemove(shell, () => { + ticker.stop(); + canvas.removeEventListener("pointerdown", handlePointerDown); + canvas.removeEventListener("pointermove", handlePointerMove); + canvas.removeEventListener("pointerup", handlePointerUp); + canvas.removeEventListener("pointercancel", handlePointerUp); + canvas.removeEventListener("pointerleave", handlePointerLeave); + canvas.removeEventListener("wheel", handleWheel); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }); + + return shell; +}; + +const isArrowKey = (key: string): boolean => + key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight"; + +const isTextEditingTarget = (target: EventTarget | null): boolean => + target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement; + +const createIsometricDungeonMapEditor = (initialText: string): DungeonMapEditorSurface => { + const surface = document.createElement("div"); + const editor = document.createElement("textarea"); + const legend = createIsometricDungeonMapLegend(); + + surface.style.position = "relative"; + surface.style.marginTop = "18px"; + editor.value = initialText; + editor.setAttribute("aria-label", "Dungeon map editor"); + editor.spellcheck = false; + editor.style.boxSizing = "border-box"; + editor.style.width = "100%"; + editor.style.minHeight = "190px"; + editor.style.padding = "12px"; + editor.style.border = "1px solid rgba(148, 163, 184, 0.28)"; + editor.style.borderRadius = "8px"; + editor.style.background = "rgba(5, 7, 10, 0.72)"; + editor.style.color = "#dbeafe"; + editor.style.font = "13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + editor.style.lineHeight = "1.35"; + editor.style.resize = "vertical"; + editor.style.whiteSpace = "pre"; + editor.style.overflow = "auto"; + + surface.append(editor); + + return { editor, element: surface, legend: legend.element, resetLegendPosition: legend.resetPosition }; +}; + +const createIsometricDungeonMapLegend = (): DungeonMapLegendSurface => { + const legend = document.createElement("aside"); + const title = document.createElement("button"); + const list = document.createElement("dl"); + const position = { + left: 0, + restingLeft: 0, + restingTop: 10, + pointerX: 0, + pointerY: 0, + startX: 0, + startY: 0, + top: 10, + }; + let collapsed = true; + let dragged = false; + let dragEnabled = false; + + legend.setAttribute("aria-label", "Dungeon map legend"); + legend.style.position = "absolute"; + legend.style.left = `${position.left}px`; + legend.style.top = `${position.top}px`; + legend.style.zIndex = "2"; + legend.style.boxSizing = "border-box"; + legend.style.border = "1px solid rgba(148, 163, 184, 0.38)"; + legend.style.borderRadius = "8px"; + legend.style.background = "rgba(12, 18, 27, 0.95)"; + legend.style.boxShadow = "0 14px 30px rgba(0, 0, 0, 0.3)"; + legend.style.color = "#dbeafe"; + legend.style.font = "12px Inter, ui-sans-serif, system-ui, sans-serif"; + legend.style.userSelect = "none"; + + title.type = "button"; + title.setAttribute("aria-label", "Toggle dungeon map legend"); + title.setAttribute("aria-expanded", "false"); + title.style.display = "block"; + title.style.width = "100%"; + title.style.height = "32px"; + title.style.padding = "0 10px"; + title.style.border = "0"; + title.style.background = "rgba(30, 41, 59, 0.92)"; + title.style.color = "#f8fafc"; + title.style.cursor = "grab"; + title.style.font = "700 12px Inter, ui-sans-serif, system-ui, sans-serif"; + title.style.textAlign = "center"; + + list.style.display = "grid"; + list.style.gridTemplateColumns = "max-content 1fr"; + list.style.gap = "6px 10px"; + list.style.margin = "0"; + list.style.padding = "10px"; + + Object.entries(isometricDungeonTiles).forEach(([symbol, tile]) => { + if (tile.role === "void") { + return; + } + + const term = document.createElement("dt"); + const description = document.createElement("dd"); + + term.textContent = symbol === " " ? "space" : symbol; + term.style.display = "grid"; + term.style.placeItems = "center"; + term.style.minWidth = "32px"; + term.style.height = "22px"; + term.style.margin = "0"; + term.style.border = "1px solid rgba(148, 163, 184, 0.3)"; + term.style.borderRadius = "4px"; + term.style.background = "rgba(5, 7, 10, 0.64)"; + term.style.color = "#f6e05e"; + term.style.font = "700 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + + description.textContent = `${tile.label} ยท ${tile.role}`; + description.style.margin = "0"; + description.style.color = "#cbd5e1"; + description.style.lineHeight = "22px"; + + list.append(term, description); + }); + + const applyLegendState = (): void => { + if (collapsed) { + position.top = position.restingTop; + } + + title.textContent = collapsed ? "?" : "Legend"; + title.setAttribute("aria-expanded", String(!collapsed)); + title.style.borderBottom = collapsed ? "0" : "1px solid rgba(148, 163, 184, 0.24)"; + title.style.textAlign = collapsed ? "center" : "left"; + legend.style.width = collapsed ? "34px" : "min(220px, calc(100% - 24px))"; + legend.style.maxHeight = collapsed ? "34px" : "190px"; + legend.style.overflow = collapsed ? "hidden" : "auto"; + list.hidden = collapsed; + updateLegendRestingPosition(); + clampLegendPosition(); + }; + + const updateLegendRestingPosition = (): void => { + const parent = legend.parentElement; + + if (!parent || !collapsed) { + return; + } + + const parentBounds = parent.getBoundingClientRect(); + const legendBounds = legend.getBoundingClientRect(); + + position.restingLeft = Math.max(0, parentBounds.width - legendBounds.width - 16); + position.left = position.restingLeft; + position.top = position.restingTop; + }; + + const clampLegendPosition = (): void => { + const parent = legend.parentElement; + + if (!parent) { + return; + } + + const parentBounds = parent.getBoundingClientRect(); + const legendBounds = legend.getBoundingClientRect(); + const maxLeft = Math.max(0, parentBounds.width - legendBounds.width); + const maxTop = Math.max(0, parentBounds.height - legendBounds.height); + + position.left = Math.min(Math.max(0, position.left), maxLeft); + position.top = Math.min(Math.max(0, position.top), maxTop); + legend.style.left = `${position.left}px`; + legend.style.top = `${position.top}px`; + }; + + title.addEventListener("pointerdown", (event) => { + dragEnabled = !collapsed; + + if (dragEnabled) { + title.setPointerCapture(event.pointerId); + } + + title.style.cursor = "grabbing"; + position.pointerX = event.clientX - position.left; + position.pointerY = event.clientY - position.top; + position.startX = event.clientX; + position.startY = event.clientY; + dragged = false; + }); + + title.addEventListener("pointermove", (event) => { + if (!dragEnabled || !title.hasPointerCapture(event.pointerId)) { + return; + } + + if (Math.hypot(event.clientX - position.startX, event.clientY - position.startY) > 3) { + dragged = true; + } + + position.left = event.clientX - position.pointerX; + position.top = event.clientY - position.pointerY; + clampLegendPosition(); + }); + + const stopLegendDrag = (event: PointerEvent, shouldToggle: boolean): void => { + title.style.cursor = "grab"; + + if (title.hasPointerCapture(event.pointerId)) { + title.releasePointerCapture(event.pointerId); + } + + if (shouldToggle && !dragged) { + collapsed = !collapsed; + applyLegendState(); + } + + dragEnabled = false; + }; + + title.addEventListener("pointerup", (event) => { + stopLegendDrag(event, true); + }); + title.addEventListener("pointercancel", (event) => { + stopLegendDrag(event, false); + }); + legend.append(title, list); + applyLegendState(); + + return { + element: legend, + resetPosition: () => { + if (!collapsed) { + return; + } + + updateLegendRestingPosition(); + clampLegendPosition(); + }, + }; +}; + +const getIsometricDungeonStats = ( + { dungeonMap, keyboard, pointer }: SceneContext +): Array => { + const column = getIsometricDungeonColumn(keyboard.playerX, dungeonMap.width); + const row = getIsometricDungeonRow(keyboard.playerZ, dungeonMap.height); + + return [ + `${column}, ${row}`, + keyboard.interactionLabel, + `${Math.round(pointer.x * 180)}deg / ${pointer.zoom.toFixed(2)}x`, + ]; +}; + +const drawNeonRacer = ( + { canvas, context, elapsed, pointer }: SceneContext, + args: Arcade3DStoryArgs +): void => { + const viewport = { height: canvas.height, width: canvas.width }; + const centerX = canvas.width / 2 + pointer.x * 76; + const horizon = canvas.height * 0.4 + pointer.y * 26; + const roadDepth = Math.max(18, args.depth); + const roadY = (z: number): number => 282 - z * 4.5; + const roadCurve = (z: number): number => + Math.sin(elapsed * 0.24 + z * 0.18) * 24 * getDepthProgress(z, roadDepth) + pointer.x * z * 2.4; + const roadPoint = (x: number, z: number) => + projectPerspectivePoint( + { x: x + roadCurve(z), y: roadY(z), z }, + viewport, + { centerX, focalLength: 470, horizon } + ); + + fillCanvasWithTrail(context, canvas, args.backgroundColor, Math.min(args.trailOpacity, 0.12)); + + const sky = context.createLinearGradient(0, 0, 0, canvas.height); + + sky.addColorStop(0, "#08111f"); + sky.addColorStop(0.42, "#171126"); + sky.addColorStop(0.7, "#241529"); + sky.addColorStop(1, "#05070a"); + context.fillStyle = sky; + context.fillRect(0, 0, canvas.width, canvas.height); + + const haze = context.createLinearGradient(0, horizon - 70, 0, horizon + 90); + + haze.addColorStop(0, colorWithAlpha(args.accentColor, 0)); + haze.addColorStop(0.54, colorWithAlpha(args.accentColor, 0.18)); + haze.addColorStop(1, colorWithAlpha("#05070a", 0)); + context.fillStyle = haze; + context.fillRect(0, Math.max(0, horizon - 80), canvas.width, 190); + + for (let index = 0; index < 24; index++) { + const width = 28 + (index % 5) * 14; + const height = 42 + ((index * 19) % 76); + const x = ((index * 83 + Math.floor(elapsed * 12)) % (canvas.width + 140)) - 70; + const y = horizon - height + 14 + Math.sin(index) * 10; + + context.fillStyle = colorWithAlpha(index % 3 === 0 ? "#24324f" : "#101827", 0.72); + context.fillRect(x, y, width, height); + + if (index % 2 === 0) { + context.fillStyle = colorWithAlpha(args.secondaryColor, 0.2); + for (let windowY = y + 10; windowY < horizon + 4; windowY += 16) { + context.fillRect(x + width * 0.25, windowY, 3, 5); + context.fillRect(x + width * 0.62, windowY + 4, 3, 5); + } + } + } + + drawCanvasLine( + context, + { x: 0, y: horizon + 8 }, + { x: canvas.width, y: horizon + 8 }, + colorWithAlpha(args.accentColor, 0.28), + 2 + ); + + const segmentCount = 18; + + for (let segment = segmentCount - 1; segment >= 0; segment--) { + const zFar = 0.8 + (roadDepth / segmentCount) * (segment + 1); + const zNear = 0.8 + (roadDepth / segmentCount) * segment; + const leftNear = roadPoint(-340, zNear); + const rightNear = roadPoint(340, zNear); + const rightFar = roadPoint(340, zFar); + const leftFar = roadPoint(-340, zFar); + const stripAlpha = segment % 2 === 0 ? 0.96 : 0.88; + + drawCanvasPolygon( + context, + [leftNear, rightNear, rightFar, leftFar], + colorWithAlpha(segment % 2 === 0 ? "#0a101b" : "#101827", stripAlpha), + colorWithAlpha(args.accentColor, 0.04) + ); + + drawCanvasPolygon( + context, + [roadPoint(-430, zNear), leftNear, leftFar, roadPoint(-430, zFar)], + colorWithAlpha("#14121e", 0.82), + colorWithAlpha("#f472b6", 0.16) + ); + drawCanvasPolygon( + context, + [rightNear, roadPoint(430, zNear), roadPoint(430, zFar), rightFar], + colorWithAlpha("#14121e", 0.82), + colorWithAlpha(args.secondaryColor, 0.13) + ); + } + + [-340, 340].forEach((edgeX) => { + drawCanvasLine( + context, + roadPoint(edgeX, roadDepth), + roadPoint(edgeX, 0.8), + colorWithAlpha(edgeX < 0 ? "#f472b6" : args.secondaryColor, 0.74), + 3 + ); + }); + + [-115, 0, 115].forEach((laneX) => { + for (let dash = 0; dash < 9; dash++) { + const z = getLoopedDepth({ + depth: roadDepth, + elapsedSeconds: elapsed, + index: dash, + spacing: 4, + speed: args.speed * 8.5, + }); + const near = roadPoint(laneX, Math.max(0.8, z - 0.85)); + const far = roadPoint(laneX, z + 0.85); + const alpha = 0.14 + getDepthProgress(z, roadDepth) * 0.72; + + drawCanvasLine(context, far, near, colorWithAlpha("#dbeafe", alpha), Math.max(1, near.scale * 5)); + } + }); + + const obstacleCount = Math.min(Math.max(10, args.objectCount), 34); + const obstacles = Array.from({ length: obstacleCount }, (_, index) => { + const z = getLoopedDepth({ + depth: roadDepth, + elapsedSeconds: elapsed, + index, + offset: 2.5, + spacing: 2.45, + speed: args.speed * 7.2, + }); + const lane = [-1, 1, 0, -2, 2][index % 5]; + const side = index % 2 === 0 ? -1 : 1; + const kind = index % 7 === 0 ? "gate" : index % 4 === 0 ? "traffic" : index % 3 === 0 ? "sign" : "barrier"; + + return { index, kind, lane, side, z }; + }); + + obstacles + .sort((a, b) => b.z - a.z) + .forEach((obstacle) => { + const progress = getDepthProgress(obstacle.z, roadDepth); + const scaleAlpha = 0.24 + progress * 0.72; + + if (obstacle.kind === "gate") { + const leftBase = roadPoint(-372, obstacle.z); + const rightBase = roadPoint(372, obstacle.z); + const topLift = 118 * leftBase.scale; + const beamLift = 92 * leftBase.scale; + + drawCanvasLine( + context, + leftBase, + { x: leftBase.x, y: leftBase.y - topLift }, + colorWithAlpha("#f472b6", scaleAlpha), + Math.max(2, 7 * leftBase.scale) + ); + drawCanvasLine( + context, + rightBase, + { x: rightBase.x, y: rightBase.y - topLift }, + colorWithAlpha(args.secondaryColor, scaleAlpha), + Math.max(2, 7 * rightBase.scale) + ); + drawCanvasLine( + context, + { x: leftBase.x, y: leftBase.y - beamLift }, + { x: rightBase.x, y: rightBase.y - beamLift }, + colorWithAlpha(args.accentColor, scaleAlpha), + Math.max(2, 6 * leftBase.scale) + ); + return; + } + + const point = roadPoint(obstacle.kind === "sign" ? obstacle.side * 395 : obstacle.lane * 118, obstacle.z); + + if (point.x < -90 || point.x > canvas.width + 90) { + return; + } + + if (obstacle.kind === "traffic") { + drawNeonRacerVehicle(context, point.x, point.y, point.scale, { + body: obstacle.index % 2 === 0 ? "#f472b6" : args.secondaryColor, + glass: args.accentColor, + outline: colorWithAlpha("#f8fafc", scaleAlpha), + shadow: colorWithAlpha("#05070a", 0.35 + progress * 0.22), + }); + return; + } + + if (obstacle.kind === "sign") { + const width = 58 * point.scale; + const height = 34 * point.scale; + + drawCanvasLine( + context, + { x: point.x, y: point.y }, + { x: point.x, y: point.y - 58 * point.scale }, + colorWithAlpha("#dbeafe", scaleAlpha), + Math.max(1, 3 * point.scale) + ); + drawCanvasPolygon( + context, + [ + { x: point.x - width, y: point.y - 72 * point.scale }, + { x: point.x + width, y: point.y - 72 * point.scale }, + { x: point.x + width * 0.82, y: point.y - 72 * point.scale - height }, + { x: point.x - width * 0.82, y: point.y - 72 * point.scale - height }, + ], + colorWithAlpha(args.accentColor, 0.16 + progress * 0.36), + colorWithAlpha(args.accentColor, scaleAlpha) + ); + return; + } + + const width = 46 * point.scale; + const height = 34 * point.scale; + + drawCanvasPolygon( + context, + [ + { x: point.x - width, y: point.y }, + { x: point.x + width, y: point.y }, + { x: point.x + width * 0.72, y: point.y - height }, + { x: point.x - width * 0.72, y: point.y - height }, + ], + colorWithAlpha(obstacle.index % 2 === 0 ? args.secondaryColor : "#f472b6", 0.18 + progress * 0.46), + colorWithAlpha("#f8fafc", 0.18 + progress * 0.42) + ); + }); + + const playerX = canvas.width / 2 + pointer.x * 34; + const playerY = canvas.height - 52 + pointer.y * 4; + + drawNeonRacerVehicle(context, playerX, playerY, 1.18, { + body: args.accentColor, + glass: "#dbeafe", + outline: "#f8fafc", + shadow: colorWithAlpha("#05070a", 0.52), + }); +}; + +const drawNeonRacerVehicle = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + scale: number, + colors: { + body: string; + glass: string; + outline: string; + shadow: string; + } +): void => { + const width = 72 * scale; + const height = 72 * scale; + + context.fillStyle = colors.shadow; + context.beginPath(); + context.ellipse(x, y + 8 * scale, width * 0.62, 10 * scale, 0, 0, Math.PI * 2); + context.fill(); + + drawCanvasPolygon( + context, + [ + { x: x - width * 0.64, y }, + { x: x + width * 0.64, y }, + { x: x + width * 0.46, y: y - height * 0.38 }, + { x: x - width * 0.46, y: y - height * 0.38 }, + ], + colorWithAlpha(colors.body, 0.86), + colors.outline + ); + drawCanvasPolygon( + context, + [ + { x: x - width * 0.36, y: y - height * 0.4 }, + { x: x + width * 0.36, y: y - height * 0.4 }, + { x: x + width * 0.2, y: y - height * 0.78 }, + { x: x - width * 0.2, y: y - height * 0.78 }, + ], + colorWithAlpha("#05070a", 0.88), + colorWithAlpha(colors.glass, 0.78) + ); + drawCanvasPolygon( + context, + [ + { x: x - width * 0.82, y: y - height * 0.02 }, + { x: x - width * 0.58, y: y - height * 0.44 }, + { x: x - width * 0.46, y: y - height * 0.38 }, + { x: x - width * 0.64, y }, + ], + colorWithAlpha("#f472b6", 0.5), + colorWithAlpha("#f472b6", 0.76) + ); + drawCanvasPolygon( + context, + [ + { x: x + width * 0.82, y: y - height * 0.02 }, + { x: x + width * 0.58, y: y - height * 0.44 }, + { x: x + width * 0.46, y: y - height * 0.38 }, + { x: x + width * 0.64, y }, + ], + colorWithAlpha(colors.body, 0.4), + colorWithAlpha(colors.body, 0.82) + ); + + drawCanvasLine( + context, + { x: x - width * 0.36, y: y - height * 0.14 }, + { x: x - width * 0.72, y: y + height * 0.05 }, + colorWithAlpha("#f8fafc", 0.7), + Math.max(1, 3 * scale) + ); + drawCanvasLine( + context, + { x: x + width * 0.36, y: y - height * 0.14 }, + { x: x + width * 0.72, y: y + height * 0.05 }, + colorWithAlpha("#f8fafc", 0.7), + Math.max(1, 3 * scale) + ); + drawCanvasLine( + context, + { x: x - width * 0.44, y: y - height * 0.02 }, + { x: x + width * 0.44, y: y - height * 0.02 }, + colorWithAlpha(colors.glass, 0.72), + Math.max(1, 2 * scale) + ); +}; + +const drawStarfighterRun = ( + { canvas, context, elapsed, pointer }: SceneContext, + args: Arcade3DStoryArgs +): void => { + const viewport = { height: canvas.height, width: canvas.width }; + const centerX = canvas.width / 2 + pointer.x * 84; + const horizon = canvas.height * 0.45 + pointer.y * 42; + const depth = Math.max(24, args.depth); + const project = (x: number, y: number, z: number, focalLength = 430) => + projectPerspectivePoint({ x, y, z }, viewport, { centerX, focalLength, horizon }); + + fillCanvasWithTrail(context, canvas, "#030611", Math.min(args.trailOpacity, 0.18)); + + const space = context.createLinearGradient(0, 0, 0, canvas.height); + + space.addColorStop(0, "#08101f"); + space.addColorStop(0.44, "#071426"); + space.addColorStop(0.72, "#070817"); + space.addColorStop(1, "#02040c"); + context.fillStyle = space; + context.fillRect(0, 0, canvas.width, canvas.height); + + const nebula = context.createRadialGradient( + canvas.width * 0.28, + canvas.height * 0.28, + 0, + canvas.width * 0.28, + canvas.height * 0.28, + canvas.width * 0.48 + ); + + nebula.addColorStop(0, colorWithAlpha(args.accentColor, 0.12)); + nebula.addColorStop(0.45, colorWithAlpha(args.secondaryColor, 0.04)); + nebula.addColorStop(1, colorWithAlpha("#02040c", 0)); + context.fillStyle = nebula; + context.fillRect(0, 0, canvas.width, canvas.height); + + for (let ring = 7; ring >= 0; ring--) { + const z = getLoopedDepth({ + depth, + elapsedSeconds: elapsed, + index: ring, + spacing: 4.2, + speed: args.speed * 5.4, + }); + const progress = getDepthProgress(z, depth); + const radiusX = 95 + progress * canvas.width * 0.58; + const radiusY = 42 + progress * canvas.height * 0.36; + const alpha = 0.04 + progress * 0.24; + + context.strokeStyle = colorWithAlpha(ring % 2 === 0 ? args.accentColor : args.secondaryColor, alpha); + context.lineWidth = Math.max(1, progress * 3); + context.beginPath(); + context.ellipse(centerX, horizon, radiusX, radiusY, 0, 0, Math.PI * 2); + context.stroke(); + } + + for (let spoke = 0; spoke < 14; spoke++) { + const angle = (Math.PI * 2 * spoke) / 14 + Math.sin(elapsed * 0.2) * 0.08; + const inner = { + x: centerX + Math.cos(angle) * 64, + y: horizon + Math.sin(angle) * 30, + }; + const outer = { + x: centerX + Math.cos(angle) * canvas.width * 0.68, + y: horizon + Math.sin(angle) * canvas.height * 0.42, + }; + + drawCanvasLine(context, inner, outer, colorWithAlpha(args.accentColor, 0.08), 1); + } + + for (let index = 0; index < args.objectCount; index++) { + const seed = index * 47.7; + const z = getLoopedDepth({ + depth, + elapsedSeconds: elapsed, + offset: seed, + speed: args.speed * 15, + }); + const angle = seed * 1.618; + const radius = 80 + (index % 10) * 42; + const x = Math.cos(angle) * radius + Math.sin(elapsed * 0.18 + seed) * 28; + const y = Math.sin(angle * 1.35) * radius * 0.56; + const point = project(x, y, z, 450); + const tail = project(x * 1.03, y * 1.03, z + 2.4, 450); + const progress = getDepthProgress(z, depth); + + drawCanvasLine( + context, + tail, + point, + colorWithAlpha(index % 7 === 0 ? args.secondaryColor : "#dbeafe", 0.14 + progress * 0.62), + Math.max(1, 1 + progress * 4) + ); + } + + const enemies = Array.from({ length: Math.min(10, Math.max(4, Math.floor(args.objectCount / 7))) }, (_, index) => { + const z = getLoopedDepth({ + depth, + elapsedSeconds: elapsed, + index, + offset: 7, + spacing: 4.8, + speed: args.speed * 5.1, + }); + const orbit = elapsed * 0.72 + index * 1.7; + + return { + index, + point: project(Math.sin(orbit * 0.9) * 235, Math.cos(orbit * 1.15) * 95, z, 420), + roll: Math.sin(orbit) * 0.7, + z, + }; + }); + + enemies + .sort((a, b) => b.z - a.z) + .forEach(({ index, point, roll, z }) => { + const progress = getDepthProgress(z, depth); + const radius = 24 * point.scale; + + drawStarfighterBogey(context, point.x, point.y, Math.max(0.25, point.scale), roll, { + body: index % 2 === 0 ? args.secondaryColor : "#f472b6", + glow: args.accentColor, + outline: colorWithAlpha("#f8fafc", 0.18 + progress * 0.58), + }); + + context.strokeStyle = colorWithAlpha(index % 2 === 0 ? args.secondaryColor : args.accentColor, 0.3 + progress * 0.5); + context.lineWidth = Math.max(1, 2 * point.scale); + context.beginPath(); + context.arc(point.x, point.y, radius * 1.75, 0, Math.PI * 2); + context.moveTo(point.x - radius * 2.25, point.y); + context.lineTo(point.x - radius * 1.15, point.y); + context.moveTo(point.x + radius * 1.15, point.y); + context.lineTo(point.x + radius * 2.25, point.y); + context.moveTo(point.x, point.y - radius * 2.25); + context.lineTo(point.x, point.y - radius * 1.15); + context.moveTo(point.x, point.y + radius * 1.15); + context.lineTo(point.x, point.y + radius * 2.25); + context.stroke(); + }); + + const playerX = canvas.width / 2 + pointer.x * 42; + const playerY = canvas.height - 52 + pointer.y * 10; + const roll = pointer.x * 0.42 + Math.sin(elapsed * args.speed * 1.6) * 0.04; + + drawStarfighterPlayerShip(context, playerX, playerY, 1.05, roll, { + accent: args.accentColor, + canopy: "#dbeafe", + hull: "#cbd5e1", + shadow: colorWithAlpha("#02040c", 0.62), + trim: args.secondaryColor, + }); +}; + +const rotatePoint = ( + origin: { x: number; y: number }, + point: { x: number; y: number }, + angle: number +): { x: number; y: number } => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = point.x - origin.x; + const y = point.y - origin.y; + + return { + x: origin.x + x * cos - y * sin, + y: origin.y + x * sin + y * cos, + }; +}; + +const transformShipPoints = ( + x: number, + y: number, + scale: number, + roll: number, + points: Array<{ x: number; y: number }> +): Array<{ x: number; y: number }> => + points.map((point) => rotatePoint({ x, y }, { x: x + point.x * scale, y: y + point.y * scale }, roll)); + +const drawStarfighterPlayerShip = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + scale: number, + roll: number, + colors: { + accent: string; + canopy: string; + hull: string; + shadow: string; + trim: string; + } +): void => { + context.fillStyle = colors.shadow; + context.beginPath(); + context.ellipse(x, y + 12 * scale, 106 * scale, 16 * scale, roll * 0.35, 0, Math.PI * 2); + context.fill(); + + const drawFace = ( + points: Array<{ x: number; y: number }>, + fill: string, + stroke = colorWithAlpha("#f8fafc", 0.42) + ): void => { + drawCanvasPolygon(context, transformShipPoints(x, y, scale, roll, points), fill, stroke); + }; + + drawFace( + [ + { x: -18, y: -88 }, + { x: 0, y: -122 }, + { x: 18, y: -88 }, + { x: 10, y: -28 }, + { x: -10, y: -28 }, + ], + colorWithAlpha(colors.hull, 0.92) + ); + drawFace( + [ + { x: -10, y: -82 }, + { x: 0, y: -105 }, + { x: 10, y: -82 }, + { x: 7, y: -54 }, + { x: -7, y: -54 }, + ], + colorWithAlpha(colors.canopy, 0.78), + colorWithAlpha(colors.canopy, 0.86) + ); + drawFace( + [ + { x: -14, y: -30 }, + { x: 0, y: -54 }, + { x: 14, y: -30 }, + { x: 28, y: 10 }, + { x: -28, y: 10 }, + ], + colorWithAlpha("#64748b", 0.84) + ); + drawFace( + [ + { x: -18, y: -42 }, + { x: -108, y: -16 }, + { x: -142, y: 26 }, + { x: -38, y: 8 }, + ], + colorWithAlpha(colors.accent, 0.72), + colorWithAlpha(colors.accent, 0.9) + ); + drawFace( + [ + { x: 18, y: -42 }, + { x: 108, y: -16 }, + { x: 142, y: 26 }, + { x: 38, y: 8 }, + ], + colorWithAlpha(colors.accent, 0.58), + colorWithAlpha(colors.accent, 0.84) + ); + drawFace( + [ + { x: -92, y: -10 }, + { x: -136, y: -34 }, + { x: -116, y: 18 }, + ], + colorWithAlpha(colors.trim, 0.86), + colorWithAlpha(colors.trim, 0.9) + ); + drawFace( + [ + { x: 92, y: -10 }, + { x: 136, y: -34 }, + { x: 116, y: 18 }, + ], + colorWithAlpha(colors.trim, 0.74), + colorWithAlpha(colors.trim, 0.88) + ); + drawFace( + [ + { x: -28, y: 4 }, + { x: -8, y: 24 }, + { x: -34, y: 34 }, + { x: -58, y: 12 }, + ], + colorWithAlpha("#111827", 0.88), + colorWithAlpha(colors.accent, 0.74) + ); + drawFace( + [ + { x: 28, y: 4 }, + { x: 8, y: 24 }, + { x: 34, y: 34 }, + { x: 58, y: 12 }, + ], + colorWithAlpha("#111827", 0.88), + colorWithAlpha(colors.trim, 0.7) + ); + + const leftEngine = transformShipPoints(x, y, scale, roll, [ + { x: -58, y: 18 }, + { x: -44, y: 32 }, + { x: -78, y: 64 }, + { x: -88, y: 28 }, + ]); + const rightEngine = transformShipPoints(x, y, scale, roll, [ + { x: 58, y: 18 }, + { x: 44, y: 32 }, + { x: 78, y: 64 }, + { x: 88, y: 28 }, + ]); + + drawCanvasPolygon(context, leftEngine, colorWithAlpha(colors.accent, 0.34), colorWithAlpha(colors.accent, 0.58)); + drawCanvasPolygon(context, rightEngine, colorWithAlpha(colors.trim, 0.3), colorWithAlpha(colors.trim, 0.52)); +}; + +const drawStarfighterBogey = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + scale: number, + roll: number, + colors: { + body: string; + glow: string; + outline: string; + } +): void => { + const drawFace = (points: Array<{ x: number; y: number }>, fill: string): void => { + drawCanvasPolygon(context, transformShipPoints(x, y, scale, roll, points), fill, colors.outline); + }; + + drawFace( + [ + { x: 0, y: -38 }, + { x: 16, y: 8 }, + { x: 0, y: 24 }, + { x: -16, y: 8 }, + ], + colorWithAlpha(colors.body, 0.72) + ); + drawFace( + [ + { x: -12, y: 4 }, + { x: -52, y: 20 }, + { x: -18, y: 30 }, + ], + colorWithAlpha(colors.glow, 0.5) + ); + drawFace( + [ + { x: 12, y: 4 }, + { x: 52, y: 20 }, + { x: 18, y: 30 }, + ], + colorWithAlpha(colors.glow, 0.42) + ); +}; + +const drawIsometricDungeon = ( + { canvas, context, delta, dungeonMap, elapsed, keyboard, pointer }: SceneContext, + args: Arcade3DStoryArgs +): void => { + const pixelScale = 2; + const width = Math.max(1, Math.floor(canvas.width / pixelScale)); + const height = Math.max(1, Math.floor(canvas.height / pixelScale)); + const internalCanvas = getIsometricDungeonCanvas(width, height); + const internalContext = internalCanvas.getContext("2d"); + + if (!internalContext) { + return; + } + + internalContext.imageSmoothingEnabled = false; + drawIsometricDungeonScene(internalContext, { + args, + delta, + dungeonMap, + elapsed, + height, + keyboard, + pointer, + width, + }); + + context.save(); + context.imageSmoothingEnabled = false; + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(internalCanvas, 0, 0, canvas.width, canvas.height); + context.restore(); +}; + +const getIsometricDungeonCanvas = ( + width: number, + height: number +): HTMLCanvasElement => { + isometricDungeonCanvas ??= document.createElement("canvas"); + isometricDungeonCanvas.width = width; + isometricDungeonCanvas.height = height; + + return isometricDungeonCanvas; +}; + +const drawIsometricDungeonScene = ( + context: CanvasRenderingContext2D, + options: { + args: Arcade3DStoryArgs; + delta: number; + dungeonMap: DungeonMapState; + elapsed: number; + height: number; + keyboard: KeyboardState; + pointer: PointerState; + width: number; + } +): void => { + const { args, delta, dungeonMap, elapsed, height, keyboard, pointer, width } = options; + const tile = Math.max(12, Math.round(18 * pointer.zoom)); + const origin = { + x: width / 2, + y: height / 2, + }; + const isoOptions = { origin, tileHeight: tile, tileWidth: tile * 2 }; + const rotation = pointer.x * Math.PI; + + updateIsometricDungeonPlayer(keyboard, dungeonMap, rotation, delta, args.speed); + updateIsometricDungeonInteractions(keyboard, dungeonMap, delta); + drawIsometricDungeonBackdrop(context, width, height, args.backgroundColor); + + const camera = { + focusX: keyboard.playerX, + focusZ: keyboard.playerZ, + }; + const tiles = dungeonMap.rows.flatMap((row, zIndex) => + [...row].map((tileCode, xIndex): DungeonProjectedTile => { + const gridX = getIsometricDungeonGridX(xIndex, dungeonMap.width); + const gridZ = getIsometricDungeonGridZ(zIndex, dungeonMap.height); + const corners = getRotatedIsometricTileCorners(gridX, gridZ, rotation, isoOptions, camera); + const center = getPolygonCenter(corners); + + return { + center, + corners, + facing: getIsometricDungeonPropFacing(dungeonMap, xIndex, zIndex, tileCode as IsometricDungeonTile), + gridX, + gridZ, + tileKind: tileCode as IsometricDungeonTile, + xIndex, + zIndex, + }; + }) + ); + const renderables: DungeonRenderable[] = []; + let renderOrder = 0; + const addRenderable = (depth: number, draw: () => void): void => { + renderables.push({ depth, draw, order: renderOrder }); + renderOrder += 1; + }; + + tiles + .slice() + .sort((first, second) => first.center.y - second.center.y) + .forEach((tileInfo) => { + if (tileInfo.tileKind === "_") { + return; + } + + if (tileInfo.tileKind === "#") { + enqueueIsometricDungeonWallRenderables(addRenderable, context, tileInfo.corners, tile); + return; + } + + drawIsometricDungeonFloor(context, tileInfo, keyboard); + if (tileInfo.tileKind === "o") { + drawIsometricDungeonTorchLight(context, tileInfo.center, tile, elapsed, args); + } + + if (tileInfo.tileKind === "D") { + enqueueIsometricDungeonDoorRenderables(addRenderable, context, tileInfo, keyboard, args); + return; + } + + if (tileInfo.tileKind !== " " && tileInfo.tileKind !== "S") { + addRenderable( + tileInfo.center.y + getIsometricDungeonPropDepthOffset(tileInfo.tileKind, tile), + () => drawIsometricDungeonProp(context, tileInfo, keyboard, elapsed, args) + ); + } + }); + + addRenderable( + projectRotatedIsometricPoint(keyboard.playerX, keyboard.playerZ, rotation, isoOptions, camera).y + tile * 0.7, + () => drawIsometricDungeonPlayer(context, isoOptions, rotation, camera, keyboard, elapsed, args) + ); + + renderables + .sort((first, second) => first.depth - second.depth || first.order - second.order) + .forEach((renderable) => renderable.draw()); +}; + +const updateIsometricDungeonPlayer = ( + keyboard: KeyboardState, + dungeonMap: DungeonMapState, + rotation: number, + delta: number, + speed: number +): void => { + let screenX = 0; + let screenZ = 0; + + if (keyboard.pressed.has("ArrowLeft")) { + screenX -= 1; + } + + if (keyboard.pressed.has("ArrowRight")) { + screenX += 1; + } + + if (keyboard.pressed.has("ArrowUp")) { + screenZ -= 1; + } + + if (keyboard.pressed.has("ArrowDown")) { + screenZ += 1; + } + + if (screenX === 0 && screenZ === 0) { + return; + } + + const magnitude = Math.hypot(screenX, screenZ); + const normalizedX = screenX / magnitude; + const normalizedZ = screenZ / magnitude; + const cos = Math.cos(-rotation); + const sin = Math.sin(-rotation); + const worldX = normalizedX * cos - normalizedZ * sin; + const worldZ = normalizedX * sin + normalizedZ * cos; + const currentTile = getIsometricDungeonTileAt(dungeonMap, keyboard.playerX, keyboard.playerZ); + const terrainSpeed = currentTile === "w" ? 0.65 : 1; + const step = delta * (2.4 + speed * 0.35) * terrainSpeed; + const nextX = keyboard.playerX + worldX * step; + const nextZ = keyboard.playerZ + worldZ * step; + + keyboard.facingX = worldX; + keyboard.facingZ = worldZ; + + if (isIsometricDungeonWalkable(dungeonMap, nextX, keyboard.playerZ)) { + keyboard.playerX = nextX; + } + + if (isIsometricDungeonWalkable(dungeonMap, keyboard.playerX, nextZ)) { + keyboard.playerZ = nextZ; + } +}; + +const updateIsometricDungeonInteractions = ( + keyboard: KeyboardState, + dungeonMap: DungeonMapState, + delta: number +): void => { + const nearestChest = findNearestDungeonTile(dungeonMap, keyboard, "C", 1); + const nearestDoor = findNearestOpenableDungeonDoor(dungeonMap, keyboard, 1.1); + const nearestStairsUp = findNearestDungeonTile(dungeonMap, keyboard, "u", 1); + const nearestStairsDown = findNearestDungeonTile(dungeonMap, keyboard, "d", 1); + const doorCells = findStringTileMapCells(dungeonMap, "D"); + const activeDoorKeys = new Set(); + + if (nearestChest) { + keyboard.chestOpen = true; + } + + doorCells.forEach((cell) => { + const key = getDungeonDoorKey(cell.column, cell.row); + const x = cell.x + 0.5; + const z = cell.z + 0.5; + const distance = Math.hypot(keyboard.playerX - x, keyboard.playerZ - z); + const shouldOpen = distance <= 1.1 && isDungeonDoorApproachOpen(dungeonMap, keyboard, cell); + const current = keyboard.doorOpenProgress.get(key) ?? 0; + const speed = shouldOpen ? 3.8 : 1.85; + const next = current + (shouldOpen ? 1 : -1) * delta * speed; + + activeDoorKeys.add(key); + keyboard.doorOpenProgress.set(key, Math.max(0, Math.min(1, next))); + }); + [...keyboard.doorOpenProgress.keys()].forEach((key) => { + if (!activeDoorKeys.has(key)) { + keyboard.doorOpenProgress.delete(key); + } + }); + + if (nearestStairsUp || nearestStairsDown) { + keyboard.stairsReached = true; + } + + if (nearestDoor && (keyboard.doorOpenProgress.get(nearestDoor.key) ?? 0) > 0.65) { + keyboard.exitReached = true; + } + + keyboard.interactionLabel = nearestChest + ? keyboard.chestOpen + ? "chest open" + : "chest" + : nearestDoor + ? (keyboard.doorOpenProgress.get(nearestDoor.key) ?? 0) > 0.65 + ? "door open" + : "door opening" + : nearestStairsUp + ? "stairs up" + : nearestStairsDown + ? "stairs down" + : "none"; +}; + +const isIsometricDungeonWalkable = ( + dungeonMap: DungeonMapState, + x: number, + z: number +): boolean => { + const tile = getIsometricDungeonTileAt(dungeonMap, x, z); + + return tile !== undefined && isometricDungeonTiles[tile].walkable; +}; + +const getIsometricDungeonTileAt = ( + dungeonMap: DungeonMapState, + x: number, + z: number +): IsometricDungeonTile | undefined => { + return getStringTileMapCellFromCenteredPoint(dungeonMap, x, z)?.tile; +}; + +const findNearestDungeonTile = ( + dungeonMap: DungeonMapState, + keyboard: KeyboardState, + tileKind: IsometricDungeonTile, + radius: number +): { column: number; distance: number; key: string; row: number; x: number; z: number } | undefined => { + let nearest: { column: number; distance: number; key: string; row: number; x: number; z: number } | undefined; + + findStringTileMapCells(dungeonMap, tileKind).forEach((cell) => { + const x = cell.x + 0.5; + const z = cell.z + 0.5; + const distance = Math.hypot(keyboard.playerX - x, keyboard.playerZ - z); + + if (distance <= radius && (!nearest || distance < nearest.distance)) { + nearest = { column: cell.column, distance, key: getDungeonDoorKey(cell.column, cell.row), row: cell.row, x, z }; + } + }); + + return nearest; +}; + +const findNearestOpenableDungeonDoor = ( + dungeonMap: DungeonMapState, + keyboard: KeyboardState, + radius: number +): { column: number; distance: number; key: string; row: number; x: number; z: number } | undefined => { + let nearest: { column: number; distance: number; key: string; row: number; x: number; z: number } | undefined; + + findStringTileMapCells(dungeonMap, "D").forEach((cell) => { + if (!isDungeonDoorApproachOpen(dungeonMap, keyboard, cell)) { + return; + } + + const x = cell.x + 0.5; + const z = cell.z + 0.5; + const distance = Math.hypot(keyboard.playerX - x, keyboard.playerZ - z); + + if (distance <= radius && (!nearest || distance < nearest.distance)) { + nearest = { column: cell.column, distance, key: getDungeonDoorKey(cell.column, cell.row), row: cell.row, x, z }; + } + }); + + return nearest; +}; + +const isDungeonDoorApproachOpen = ( + dungeonMap: DungeonMapState, + keyboard: KeyboardState, + door: { column: number; row: number; x: number; z: number } +): boolean => { + const doorCenterX = door.x + 0.5; + const doorCenterZ = door.z + 0.5; + const axis = getIsometricDungeonDoorAxis(dungeonMap, door.column, door.row); + const approachColumn = axis === "horizontal" + ? door.column + (keyboard.playerX < doorCenterX ? -1 : 1) + : door.column; + const approachRow = axis === "horizontal" + ? door.row + : door.row + (keyboard.playerZ < doorCenterZ ? -1 : 1); + const approachTile = getStringTileMapTile(dungeonMap, approachColumn, approachRow); + + return approachTile !== undefined && isometricDungeonTiles[approachTile].walkable; +}; + +const getDungeonDoorKey = (column: number, row: number): string => `${column}:${row}`; + +const getIsometricDungeonPropFacing = ( + dungeonMap: DungeonMapState, + column: number, + row: number, + tile: IsometricDungeonTile +): IsometricDungeonDirection => { + if (tile === "D") { + return getIsometricDungeonDoorFacing(dungeonMap, column, row); + } + + if (tile === "C") { + return getIsometricDungeonChestFacing(dungeonMap, column, row); + } + + return "south"; +}; + +const getIsometricDungeonDoorFacing = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): IsometricDungeonDirection => { + const north = isIsometricDungeonCellWalkable(dungeonMap, column, row - 1); + const south = isIsometricDungeonCellWalkable(dungeonMap, column, row + 1); + const east = isIsometricDungeonCellWalkable(dungeonMap, column + 1, row); + const west = isIsometricDungeonCellWalkable(dungeonMap, column - 1, row); + const verticalWallSides = + Number(isIsometricDungeonWallAttachment(dungeonMap, column, row - 1)) + + Number(isIsometricDungeonWallAttachment(dungeonMap, column, row + 1)); + const horizontalWallSides = + Number(isIsometricDungeonWallAttachment(dungeonMap, column - 1, row)) + + Number(isIsometricDungeonWallAttachment(dungeonMap, column + 1, row)); + + if (verticalWallSides > horizontalWallSides) { + return east ? "east" : "west"; + } + + if (horizontalWallSides > verticalWallSides) { + return south ? "south" : "north"; + } + + if (east && !west) { + return "east"; + } + + if (west && !east) { + return "west"; + } + + if (south && !north) { + return "south"; + } + + if (north && !south) { + return "north"; + } + + return "south"; +}; + +const getIsometricDungeonDoorAxis = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): "horizontal" | "vertical" => { + const verticalWallSides = + Number(isIsometricDungeonWallAttachment(dungeonMap, column, row - 1)) + + Number(isIsometricDungeonWallAttachment(dungeonMap, column, row + 1)); + const horizontalWallSides = + Number(isIsometricDungeonWallAttachment(dungeonMap, column - 1, row)) + + Number(isIsometricDungeonWallAttachment(dungeonMap, column + 1, row)); + + if (verticalWallSides > horizontalWallSides) { + return "horizontal"; + } + + if (horizontalWallSides > verticalWallSides) { + return "vertical"; + } + + const verticalOpenings = + Number(isIsometricDungeonCellWalkable(dungeonMap, column, row - 1)) + + Number(isIsometricDungeonCellWalkable(dungeonMap, column, row + 1)); + const horizontalOpenings = + Number(isIsometricDungeonCellWalkable(dungeonMap, column - 1, row)) + + Number(isIsometricDungeonCellWalkable(dungeonMap, column + 1, row)); + + return horizontalOpenings > verticalOpenings ? "horizontal" : "vertical"; +}; + +const getIsometricDungeonChestFacing = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): IsometricDungeonDirection => { + const candidates: Array<{ direction: IsometricDungeonDirection; open: boolean }> = [ + { direction: "south", open: isIsometricDungeonCellWalkable(dungeonMap, column, row + 1) }, + { direction: "east", open: isIsometricDungeonCellWalkable(dungeonMap, column + 1, row) }, + { direction: "west", open: isIsometricDungeonCellWalkable(dungeonMap, column - 1, row) }, + { direction: "north", open: isIsometricDungeonCellWalkable(dungeonMap, column, row - 1) }, + ]; + + return candidates.find((candidate) => candidate.open)?.direction ?? "south"; +}; + +const isIsometricDungeonCellWalkable = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): boolean => { + const tile = getStringTileMapTile(dungeonMap, column, row); + + return tile !== undefined && isometricDungeonTiles[tile].walkable; +}; + +const isIsometricDungeonCellBlocked = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): boolean => { + const tile = getStringTileMapTile(dungeonMap, column, row); + + return tile === undefined || !isometricDungeonTiles[tile].walkable; +}; + +const isIsometricDungeonWallAttachment = ( + dungeonMap: DungeonMapState, + column: number, + row: number +): boolean => getStringTileMapTile(dungeonMap, column, row) === "#"; + +const projectRotatedIsometricPoint = ( + x: number, + z: number, + rotation: number, + isoOptions: Parameters[1], + camera: IsometricDungeonCamera +): ReturnType => { + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + const localX = x - camera.focusX; + const localZ = z - camera.focusZ; + + return projectIsometricPoint( + { + x: localX * cos - localZ * sin, + y: 0, + z: localX * sin + localZ * cos, + }, + isoOptions + ); +}; + +const getRotatedIsometricTileCorners = ( + x: number, + z: number, + rotation: number, + isoOptions: Parameters[1], + camera: IsometricDungeonCamera +): ReturnType => [ + projectRotatedIsometricPoint(x, z, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x + 1, z, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x + 1, z + 1, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x, z + 1, rotation, isoOptions, camera), +]; + +const getPolygonCenter = ( + points: ReturnType +): ReturnType => { + const total = points.reduce( + (sum, point) => ({ + x: sum.x + point.x, + y: sum.y + point.y, + }), + { x: 0, y: 0 } + ); + + return { + x: total.x / points.length, + y: total.y / points.length, + }; +}; + +const getTileLocalPoint = ( + { corners }: DungeonProjectedTile, + u: number, + v: number, + lift = 0 +): ReturnType => { + const [origin, xEdge, , zEdge] = corners; + + return { + x: origin.x + (xEdge.x - origin.x) * u + (zEdge.x - origin.x) * v, + y: origin.y + (xEdge.y - origin.y) * u + (zEdge.y - origin.y) * v - lift, + }; +}; + +const orientTileLocalCoordinates = ( + facing: IsometricDungeonDirection, + u: number, + v: number +): { u: number; v: number } => { + if (facing === "north") { + return { u: 1 - u, v: 1 - v }; + } + + if (facing === "east") { + return { u: v, v: 1 - u }; + } + + if (facing === "west") { + return { u: 1 - v, v: u }; + } + + return { u, v }; +}; - const handlePointerLeave = (): void => { - pointer.active = false; - pointer.x = 0; - pointer.y = 0; - }; +const getOrientedTileLocalPoint = ( + tileInfo: DungeonProjectedTile, + u: number, + v: number, + lift = 0 +): ReturnType => { + const oriented = orientTileLocalCoordinates(tileInfo.facing, u, v); - canvas.addEventListener("pointerdown", handlePointerDown); - canvas.addEventListener("pointermove", handlePointerMove); - canvas.addEventListener("pointerup", handlePointerUp); - canvas.addEventListener("pointercancel", handlePointerUp); - canvas.addEventListener("pointerleave", handlePointerLeave); + return getTileLocalPoint(tileInfo, oriented.u, oriented.v, lift); +}; - const render = (): void => { - const now = performance.now(); - const delta = Math.min(0.05, (now - lastTime) / 1000); +const getSwingingDoorLocalPoint = ( + tileInfo: DungeonProjectedTile, + u: number, + v: number, + openProgress: number, + lift = 0 +): ReturnType => { + const hingeU = 0.18; + const hingeV = 0.5; + const angle = -openProgress * Math.PI * 0.48; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const localU = u - hingeU; + const localV = v - hingeV; + + return getOrientedTileLocalPoint( + tileInfo, + hingeU + localU * cos - localV * sin, + hingeV + localU * sin + localV * cos, + lift + ); +}; - frame += 1; - fpsAge += delta; - fpsFrames += 1; - lastTime = now; +const getTileLocalQuad = ( + tileInfo: DungeonProjectedTile, + fromU: number, + fromV: number, + toU: number, + toV: number, + lift = 0 +): ReturnType => [ + getTileLocalPoint(tileInfo, fromU, fromV, lift), + getTileLocalPoint(tileInfo, toU, fromV, lift), + getTileLocalPoint(tileInfo, toU, toV, lift), + getTileLocalPoint(tileInfo, fromU, toV, lift), +]; + +const getOrientedTileLocalQuad = ( + tileInfo: DungeonProjectedTile, + fromU: number, + fromV: number, + toU: number, + toV: number, + lift = 0 +): ReturnType => [ + getOrientedTileLocalPoint(tileInfo, fromU, fromV, lift), + getOrientedTileLocalPoint(tileInfo, toU, fromV, lift), + getOrientedTileLocalPoint(tileInfo, toU, toV, lift), + getOrientedTileLocalPoint(tileInfo, fromU, toV, lift), +]; + +const getSwingingDoorLocalQuad = ( + tileInfo: DungeonProjectedTile, + fromU: number, + fromV: number, + toU: number, + toV: number, + openProgress: number, + lift = 0 +): ReturnType => [ + getSwingingDoorLocalPoint(tileInfo, fromU, fromV, openProgress, lift), + getSwingingDoorLocalPoint(tileInfo, toU, fromV, openProgress, lift), + getSwingingDoorLocalPoint(tileInfo, toU, toV, openProgress, lift), + getSwingingDoorLocalPoint(tileInfo, fromU, toV, openProgress, lift), +]; + +const getRotatedWorldQuad = ( + x: number, + z: number, + width: number, + depth: number, + rotation: number, + isoOptions: Parameters[1], + camera: IsometricDungeonCamera, + lift = 0 +): ReturnType => { + const halfWidth = width / 2; + const halfDepth = depth / 2; + + return [ + projectRotatedIsometricPoint(x - halfWidth, z - halfDepth, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x + halfWidth, z - halfDepth, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x + halfWidth, z + halfDepth, rotation, isoOptions, camera), + projectRotatedIsometricPoint(x - halfWidth, z + halfDepth, rotation, isoOptions, camera), + ].map((point) => ({ x: point.x, y: point.y - lift })); +}; - if (fpsAge >= 0.5) { - setValue(fpsValue, Math.round(fpsFrames / fpsAge)); - fpsAge = 0; - fpsFrames = 0; - } +const drawIsometricCuboid = ( + context: CanvasRenderingContext2D, + base: ReturnType, + height: number, + colors: { + sideA: string; + sideB: string; + sideC: string; + sideD: string; + top: string; + stroke?: string; + } +): void => { + const top = base.map((point) => ({ x: point.x, y: point.y - height })); + const sideColors = [colors.sideA, colors.sideB, colors.sideC, colors.sideD]; + const faces = base.map((from, index) => { + const nextIndex = (index + 1) % base.length; + const to = base[nextIndex]; + const topTo = top[nextIndex]; + const topFrom = top[index]; + const points = [from, to, topTo, topFrom] as ReturnType; + + return { + color: sideColors[index], + depth: getPolygonCenter(points).y, + points, + }; + }); - renderScene( - { - canvas, - context, - delta, - elapsed: now / 1000, - frame, - pointer, - }, - args - ); - }; + faces + .sort((first, second) => first.depth - second.depth) + .forEach((face) => { + drawCanvasPolygon(context, face.points, face.color, colors.stroke); + }); + drawCanvasPolygon(context, top as ReturnType, colors.top, colors.stroke); +}; - ticker.addSchedule(render, 1); - ticker.start(); - onRemove(shell, () => { - ticker.stop(); - canvas.removeEventListener("pointerdown", handlePointerDown); - canvas.removeEventListener("pointermove", handlePointerMove); - canvas.removeEventListener("pointerup", handlePointerUp); - canvas.removeEventListener("pointercancel", handlePointerUp); - canvas.removeEventListener("pointerleave", handlePointerLeave); +const enqueueIsometricCuboidRenderables = ( + addRenderable: (depth: number, draw: () => void) => void, + context: CanvasRenderingContext2D, + base: ReturnType, + height: number, + colors: { + sideA: string; + sideB: string; + sideC: string; + sideD: string; + top: string; + stroke?: string; + } +): void => { + const top = base.map((point) => ({ x: point.x, y: point.y - height })); + const sideColors = [colors.sideA, colors.sideB, colors.sideC, colors.sideD]; + const sideDepths: number[] = []; + + base.forEach((from, index) => { + const nextIndex = (index + 1) % base.length; + const to = base[nextIndex]; + const topTo = top[nextIndex]; + const topFrom = top[index]; + const points = [from, to, topTo, topFrom] as ReturnType; + + const depth = getPolygonCenter(points).y; + + sideDepths.push(depth); + addRenderable(depth, () => { + drawCanvasPolygon(context, points, sideColors[index], colors.stroke); + }); }); + addRenderable(Math.max(...sideDepths) + 0.1, () => { + drawCanvasPolygon(context, top as ReturnType, colors.top, colors.stroke); + }); +}; - return shell; +const drawIsometricDungeonBackdrop = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + backgroundColor: string +): void => { + const gradient = context.createLinearGradient(0, 0, 0, height); + + gradient.addColorStop(0, backgroundColor); + gradient.addColorStop(0.58, "#111017"); + gradient.addColorStop(1, "#06070b"); + context.fillStyle = gradient; + context.fillRect(0, 0, width, height); + context.fillStyle = "rgba(0, 0, 0, 0.28)"; + context.fillRect(0, Math.floor(height * 0.78), width, Math.ceil(height * 0.22)); }; -const drawNeonRacer = ( - { canvas, context, elapsed, pointer }: SceneContext, - args: Arcade3DStoryArgs +const drawIsometricDungeonFloor = ( + context: CanvasRenderingContext2D, + tileInfo: DungeonProjectedTile, + keyboard: KeyboardState ): void => { - const cameraOffset = pointer.x * 54; - const horizon = canvas.height * 0.38 + pointer.y * 18; - const centerX = canvas.width / 2 + cameraOffset; - const roadTop = canvas.width * 0.08; - const roadBottom = canvas.width * 0.43; - const road = [ - { x: centerX - roadTop, y: horizon }, - { x: centerX + roadTop, y: horizon }, - { x: canvas.width / 2 + roadBottom + cameraOffset * 0.22, y: canvas.height }, - { x: canvas.width / 2 - roadBottom + cameraOffset * 0.22, y: canvas.height }, - ]; + const { corners, tileKind, xIndex, zIndex } = tileInfo; + const isWater = tileKind === "w"; + const isDoor = tileKind === "D"; + const isStairs = tileKind === "u"; + const isSpawn = tileKind === "S"; + const shade = (xIndex + zIndex) % 2 === 0 ? 0.92 : 1; + const exitGlow = + (keyboard.stairsReached || keyboard.interactionLabel.startsWith("stairs")) && isStairs; + const doorGlow = keyboard.interactionLabel === "door" && isDoor; - fillCanvasWithTrail(context, canvas, args.backgroundColor, args.trailOpacity); - const sky = context.createLinearGradient(0, 0, 0, canvas.height); + drawCanvasPolygon( + context, + corners, + isWater + ? "rgba(38, 96, 112, 0.74)" + : isDoor + ? doorGlow + ? "rgba(112, 84, 48, 0.95)" + : "rgba(87, 61, 42, 0.92)" + : exitGlow + ? "rgba(89, 110, 95, 0.96)" + : isSpawn + ? "rgba(45, 56, 58, 0.95)" + : `rgba(${Math.round(42 * shade)}, ${Math.round(43 * shade)}, ${Math.round(52 * shade)}, 0.95)`, + isWater + ? "rgba(144, 205, 244, 0.46)" + : doorGlow + ? "rgba(246, 224, 94, 0.45)" + : exitGlow + ? "rgba(246, 224, 94, 0.55)" + : isSpawn + ? "rgba(79, 209, 197, 0.32)" + : "rgba(10, 11, 16, 0.7)" + ); - sky.addColorStop(0, colorWithAlpha(args.accentColor, 0.12)); - sky.addColorStop(0.52, colorWithAlpha(args.backgroundColor, 0.2)); - sky.addColorStop(1, colorWithAlpha(args.secondaryColor, 0.08)); - context.fillStyle = sky; - context.fillRect(0, 0, canvas.width, canvas.height); - drawCanvasPolygon(context, road, "rgba(8, 12, 18, 0.92)", colorWithAlpha(args.accentColor, 0.42)); + if (tileKind !== "D") { + const [top, right, bottom, left] = corners; - for (let index = 0; index < Math.max(8, args.objectCount); index++) { - const z = getLoopedDepth({ - depth: args.depth, - elapsedSeconds: elapsed, - index, - spacing: 2.6, - speed: args.speed * 7, - }); - const width = 10 + z * 5.5; - const y = horizon + (canvas.height - horizon) * (1 - z / args.depth) ** 2; - const center = centerX + Math.sin(z * 0.9 + elapsed) * 18 * (1 - z / args.depth); + drawCanvasLine(context, top, bottom, "rgba(255, 255, 255, 0.05)"); + drawCanvasLine(context, left, right, "rgba(0, 0, 0, 0.18)"); + } +}; - drawCanvasLine( - context, - { x: center - width, y }, - { x: center + width, y }, - index % 2 === 0 ? colorWithAlpha(args.secondaryColor, 0.95) : colorWithAlpha(args.accentColor, 0.85), - Math.max(1, 5 * (1 - z / args.depth)) - ); +const drawIsometricDungeonTorchLight = ( + context: CanvasRenderingContext2D, + center: { x: number; y: number }, + tile: number, + elapsed: number, + args: Arcade3DStoryArgs +): void => { + const flicker = 0.82 + Math.sin(elapsed * args.speed * 11 + center.x) * 0.12; + const gradient = context.createRadialGradient(center.x, center.y - tile * 0.25, 0, center.x, center.y - tile * 0.25, tile * 2.8); + + gradient.addColorStop(0, colorWithAlpha(args.secondaryColor, 0.28 * flicker)); + gradient.addColorStop(0.45, colorWithAlpha(args.secondaryColor, 0.1 * flicker)); + gradient.addColorStop(1, "rgba(246, 224, 94, 0)"); + context.fillStyle = gradient; + context.fillRect(center.x - tile * 3, center.y - tile * 3, tile * 6, tile * 6); +}; + +const getIsometricDungeonPropDepthOffset = ( + tileKind: IsometricDungeonTile, + tile: number +): number => { + if (tileKind === "o" || tileKind === "P") { + return tile * 1.1; } - for (let lane = -2; lane <= 2; lane++) { - const from = projectPerspectivePoint( - { x: lane * 58, y: 140, z: args.depth }, - { height: canvas.height, width: canvas.width }, - { centerX, horizon } - ); - const to = projectPerspectivePoint( - { x: lane * 210, y: 280, z: 0.5 }, - { height: canvas.height, width: canvas.width }, - { centerX, horizon } - ); + if (tileKind === "C") { + return tile * 0.8; + } - drawCanvasLine(context, from, to, colorWithAlpha(args.accentColor, lane === 0 ? 0.55 : 0.22), lane === 0 ? 2 : 1); + if (tileKind === "r") { + return tile * 0.55; } - for (let index = 0; index < Math.min(8, args.objectCount); index++) { - const z = getLoopedDepth({ - depth: args.depth, - elapsedSeconds: elapsed, - index, - offset: 4, - spacing: 4.7, - speed: args.speed * 5, + return tile * 0.45; +}; + +const enqueueIsometricDungeonWallRenderables = ( + addRenderable: (depth: number, draw: () => void) => void, + context: CanvasRenderingContext2D, + corners: ReturnType, + height: number +): void => { + const raised = corners.map((point) => ({ x: point.x, y: point.y - height })); + const shades = [ + "rgba(31, 28, 39, 0.98)", + "rgba(42, 37, 52, 0.98)", + "rgba(24, 22, 31, 0.98)", + "rgba(36, 32, 45, 0.98)", + ]; + + const faces = getIsometricWallFaces(raised, height); + + faces.forEach((face) => { + addRenderable(face.depth, () => { + drawCanvasPolygon(context, face.points, shades[face.index]); }); - const side = index % 2 === 0 ? -1 : 1; - const p = projectPerspectivePoint( - { - x: side * (120 + Math.sin(index) * 40), - y: 190, - z, - }, - { height: canvas.height, width: canvas.width }, - { centerX, horizon } - ); - const width = 38 * p.scale; - const height = 24 * p.scale; + }); + addRenderable(Math.max(...faces.map((face) => face.depth)) + 0.1, () => { + drawCanvasPolygon(context, raised as ReturnType, "rgba(62, 55, 75, 0.98)", "rgba(148, 122, 176, 0.35)"); + const [, right, , left] = raised; - drawCanvasPolygon( - context, - [ - { x: p.x - width, y: p.y + height }, - { x: p.x - width * 0.58, y: p.y - height }, - { x: p.x + width * 0.58, y: p.y - height }, - { x: p.x + width, y: p.y + height }, - ], - colorWithAlpha(index % 2 === 0 ? args.accentColor : args.secondaryColor, 0.45), - colorWithAlpha("#ffffff", 0.28) - ); - } + drawCanvasLine(context, left, right, "rgba(255, 255, 255, 0.08)"); + }); +}; - drawCanvasPolygon( +const enqueueIsometricDungeonDoorRenderables = ( + addRenderable: (depth: number, draw: () => void) => void, + context: CanvasRenderingContext2D, + tileInfo: DungeonProjectedTile, + keyboard: KeyboardState, + args: Arcade3DStoryArgs +): void => { + const doorKey = getDungeonDoorKey(tileInfo.xIndex, tileInfo.zIndex); + const openProgress = keyboard.doorOpenProgress.get(doorKey) ?? 0; + const easedOpen = 1 - (1 - openProgress) ** 2; + const doorReady = keyboard.interactionLabel.startsWith("door"); + const panel = getSwingingDoorLocalQuad( + tileInfo, + 0.18, + 0.42, + 0.82, + 0.58, + easedOpen + ); + const handle = getSwingingDoorLocalPoint(tileInfo, 0.63, 0.59, easedOpen, 14); + + enqueueIsometricCuboidRenderables( + addRenderable, context, - [ - { x: canvas.width / 2 - 56 + pointer.x * 18, y: canvas.height - 34 }, - { x: canvas.width / 2 - 30 + pointer.x * 8, y: canvas.height - 92 + pointer.y * 8 }, - { x: canvas.width / 2 + 30 + pointer.x * 8, y: canvas.height - 92 + pointer.y * 8 }, - { x: canvas.width / 2 + 56 + pointer.x * 18, y: canvas.height - 34 }, - ], - colorWithAlpha(args.accentColor, 0.78), - "#f5f7fb" + getOrientedTileLocalQuad(tileInfo, 0.02, 0.36, 0.16, 0.64), + 26, + { + sideA: "rgba(35, 24, 18, 0.94)", + sideB: "rgba(82, 53, 36, 0.94)", + sideC: "rgba(29, 21, 17, 0.94)", + sideD: "rgba(65, 43, 31, 0.94)", + top: "rgba(105, 70, 45, 0.96)", + stroke: doorReady ? "rgba(246, 224, 94, 0.48)" : "rgba(246, 224, 94, 0.14)", + } + ); + enqueueIsometricCuboidRenderables( + addRenderable, + context, + getOrientedTileLocalQuad(tileInfo, 0.84, 0.36, 0.98, 0.64), + 26, + { + sideA: "rgba(35, 24, 18, 0.94)", + sideB: "rgba(82, 53, 36, 0.94)", + sideC: "rgba(29, 21, 17, 0.94)", + sideD: "rgba(65, 43, 31, 0.94)", + top: "rgba(105, 70, 45, 0.96)", + stroke: doorReady ? "rgba(246, 224, 94, 0.62)" : "rgba(246, 224, 94, 0.18)", + } + ); + enqueueIsometricCuboidRenderables( + addRenderable, + context, + getOrientedTileLocalQuad(tileInfo, 0.02, 0.36, 0.98, 0.48), + 28, + { + sideA: "rgba(39, 27, 20, 0.96)", + sideB: "rgba(92, 59, 39, 0.96)", + sideC: "rgba(31, 22, 17, 0.96)", + sideD: "rgba(74, 48, 33, 0.96)", + top: "rgba(118, 78, 49, 0.96)", + stroke: doorReady ? "rgba(246, 224, 94, 0.5)" : "rgba(246, 224, 94, 0.12)", + } ); + enqueueIsometricCuboidRenderables(addRenderable, context, panel, 22, { + sideA: doorReady ? colorWithAlpha(args.secondaryColor, 0.22) : "rgba(55, 36, 26, 0.94)", + sideB: "rgba(112, 70, 42, 0.96)", + sideC: "rgba(37, 27, 22, 0.96)", + sideD: "rgba(92, 58, 38, 0.96)", + top: "rgba(133, 83, 48, 0.96)", + stroke: doorReady ? "rgba(246, 224, 94, 0.72)" : "rgba(246, 224, 94, 0.18)", + }); + addRenderable(handle.y, () => { + context.fillStyle = colorWithAlpha(args.secondaryColor, doorReady ? 0.95 : 0.54); + context.fillRect(handle.x - 1, handle.y - 1, 3, 3); + }); }; -const drawStarfighterRun = ( - { canvas, context, elapsed, pointer }: SceneContext, +const getIsometricWallFaces = ( + raised: ReturnType, + height: number +): Array<{ + depth: number; + index: number; + points: ReturnType; +}> => + raised.map((from, index) => { + const to = raised[(index + 1) % raised.length]; + const lowerTo = { x: to.x, y: to.y + height }; + const lowerFrom = { x: from.x, y: from.y + height }; + const points = [from, to, lowerTo, lowerFrom] as ReturnType; + + return { + depth: getPolygonCenter(points).y, + index, + points, + }; + }); + +const drawIsometricDungeonProp = ( + context: CanvasRenderingContext2D, + tileInfo: DungeonProjectedTile, + keyboard: KeyboardState, + elapsed: number, args: Arcade3DStoryArgs ): void => { - const centerX = canvas.width / 2 + pointer.x * 74; - const horizon = canvas.height * 0.46 + pointer.y * 46; + const { center, tileKind } = tileInfo; - fillCanvasWithTrail(context, canvas, args.backgroundColor, args.trailOpacity); + if (tileKind === "o") { + const flicker = 0.75 + Math.sin(elapsed * args.speed * 12 + center.x) * 0.15; - for (let index = 0; index < args.objectCount; index++) { - const seed = index * 47.7; - const z = getLoopedDepth({ - depth: args.depth, - elapsedSeconds: elapsed, - offset: seed, - speed: args.speed * 14, - }); - const angle = seed * 1.618; - const radius = 70 + (index % 9) * 38; - const point = projectPerspectivePoint( - { - x: Math.cos(angle) * radius, - y: Math.sin(angle * 1.4) * radius * 0.58, - z, - }, - { height: canvas.height, width: canvas.width }, - { centerX, focalLength: 440, horizon } - ); - const tail = projectPerspectivePoint( - { - x: Math.cos(angle) * radius, - y: Math.sin(angle * 1.4) * radius * 0.58, - z: z + 1.8, - }, - { height: canvas.height, width: canvas.width }, - { centerX, focalLength: 440, horizon } - ); + context.fillStyle = "rgba(61, 42, 28, 0.9)"; + context.fillRect(center.x - 2, center.y - 3, 4, 16); + context.fillStyle = "rgba(30, 22, 17, 0.88)"; + context.fillRect(center.x - 6, center.y + 11, 12, 3); + context.fillStyle = colorWithAlpha(args.secondaryColor, 0.52 * flicker); + context.beginPath(); + context.arc(center.x, center.y - 8, 11, 0, Math.PI * 2); + context.fill(); + context.fillStyle = colorWithAlpha("#f6e05e", 0.92); + context.fillRect(center.x - 2, center.y - 14, 4, 8); + context.fillStyle = colorWithAlpha("#fc8181", 0.62); + context.fillRect(center.x - 1, center.y - 17, 2, 5); + return; + } - drawCanvasLine( - context, - tail, - point, - colorWithAlpha(index % 5 === 0 ? args.secondaryColor : args.accentColor, 0.18 + point.scale), - Math.max(1, 4 * point.scale) - ); + if (tileKind === "P") { + const base = getTileLocalQuad(tileInfo, 0.2, 0.2, 0.8, 0.8, 0); + + context.fillStyle = "rgba(14, 15, 20, 0.48)"; + context.beginPath(); + context.ellipse(center.x, center.y + 8, 12, 5, 0, 0, Math.PI * 2); + context.fill(); + drawIsometricCuboid(context, base, 28, { + sideA: "rgba(66, 60, 82, 0.98)", + sideB: "rgba(82, 74, 101, 0.98)", + sideC: "rgba(45, 41, 58, 0.98)", + sideD: "rgba(73, 67, 87, 0.98)", + top: "rgba(122, 111, 144, 0.98)", + stroke: "rgba(148, 122, 176, 0.24)", + }); + drawCanvasPolygon(context, getTileLocalQuad(tileInfo, 0.12, 0.12, 0.88, 0.88), "rgba(88, 80, 105, 0.72)"); + return; } - for (let index = 0; index < Math.min(8, args.objectCount); index++) { - const z = getLoopedDepth({ - depth: args.depth, - elapsedSeconds: elapsed, - index, - offset: 6, - spacing: 3.9, - speed: args.speed * 4.2, + if (tileKind === "C") { + const base = getOrientedTileLocalQuad(tileInfo, 0.16, 0.28, 0.84, 0.74, 0); + const lock = getOrientedTileLocalPoint(tileInfo, 0.5, 0.74, 8); + const chestReady = keyboard.interactionLabel === "chest" || keyboard.interactionLabel === "chest open"; + + context.fillStyle = "rgba(12, 11, 14, 0.45)"; + context.fillRect(center.x - 12, center.y + 11, 24, 5); + drawIsometricCuboid(context, base, 12, { + sideA: "rgba(76, 47, 29, 0.98)", + sideB: "rgba(112, 70, 38, 0.98)", + sideC: "rgba(55, 35, 25, 0.98)", + sideD: "rgba(99, 62, 34, 0.98)", + top: keyboard.chestOpen ? "rgba(30, 24, 21, 0.98)" : "rgba(132, 83, 43, 0.98)", + stroke: chestReady ? "rgba(246, 224, 94, 0.78)" : "rgba(246, 224, 94, 0.18)", }); - const orbit = elapsed * 0.9 + index; - const target = projectPerspectivePoint( - { - x: Math.sin(orbit * 0.8) * 220, - y: Math.cos(orbit * 1.1) * 90, - z, - }, - { height: canvas.height, width: canvas.width }, - { centerX, focalLength: 420, horizon } - ); - const radius = 32 * target.scale; + if (keyboard.chestOpen || chestReady) { + const glow = context.createRadialGradient(center.x, center.y - 10, 0, center.x, center.y - 10, 20); + + gradientSafeAddColorStop(glow, [ + [0, colorWithAlpha(args.secondaryColor, keyboard.chestOpen ? 0.44 + Math.sin(elapsed * 8) * 0.08 : 0.22)], + [1, "rgba(246, 224, 94, 0)"], + ]); + context.fillStyle = glow; + context.fillRect(center.x - 24, center.y - 30, 48, 40); + } + context.fillStyle = colorWithAlpha(args.secondaryColor, 0.82); + context.fillRect(lock.x - 2, lock.y - 2, 4, 5); + return; + } - context.strokeStyle = colorWithAlpha(index % 2 === 0 ? args.secondaryColor : args.accentColor, 0.85); - context.lineWidth = Math.max(1, 3 * target.scale); - context.beginPath(); - context.arc(target.x, target.y, radius, 0, Math.PI * 2); - context.moveTo(target.x - radius * 1.45, target.y); - context.lineTo(target.x + radius * 1.45, target.y); - context.moveTo(target.x, target.y - radius * 1.45); - context.lineTo(target.x, target.y + radius * 1.45); - context.stroke(); + if (tileKind === "S") { + return; } - context.strokeStyle = colorWithAlpha("#ffffff", 0.7); - context.lineWidth = 2; - context.beginPath(); - context.moveTo(canvas.width / 2 - 24 + pointer.x * 24, canvas.height - 70); - context.lineTo(canvas.width / 2 + pointer.x * 10, canvas.height - 116 + pointer.y * 12); - context.lineTo(canvas.width / 2 + 24 + pointer.x * 24, canvas.height - 70); - context.moveTo(canvas.width / 2 - 70 + pointer.x * 34, canvas.height - 52); - context.lineTo(canvas.width / 2 + pointer.x * 10, canvas.height - 86 + pointer.y * 10); - context.lineTo(canvas.width / 2 + 70 + pointer.x * 34, canvas.height - 52); - context.stroke(); -}; + if (tileKind === "u") { + const stairsReady = keyboard.interactionLabel === (tileKind === "u" ? "stairs up" : "stairs down"); + const pulse = keyboard.stairsReached || stairsReady ? 0.32 + Math.sin(elapsed * 8) * 0.08 : 0.12; -const drawIsometricDungeon = ( - { canvas, context, elapsed, pointer }: SceneContext, - args: Arcade3DStoryArgs -): void => { - const tile = 34; - const origin = { x: canvas.width / 2 + pointer.x * 86, y: 112 + pointer.y * 46 }; - const isoOptions = { origin, tileHeight: tile, tileWidth: tile * 2 }; - const bob = Math.sin(elapsed * args.speed * 1.8) * 4; + for (let step = 0; step < 4; step++) { + drawIsometricCuboid( + context, + getTileLocalQuad(tileInfo, 0.18 + step * 0.08, 0.18 + step * 0.12, 0.82 - step * 0.08, 0.34 + step * 0.12), + 3 + step * 2, + { + sideA: `rgba(58, 54, 70, ${0.88 - step * 0.06})`, + sideB: `rgba(82, 76, 98, ${0.88 - step * 0.06})`, + sideC: `rgba(42, 39, 53, ${0.88 - step * 0.06})`, + sideD: `rgba(75, 69, 88, ${0.88 - step * 0.06})`, + top: `rgba(95, 88, 108, ${0.9 - step * 0.05})`, + stroke: stairsReady ? "rgba(246, 224, 94, 0.5)" : "rgba(255, 255, 255, 0.1)", + } + ); + } + context.fillStyle = colorWithAlpha(args.secondaryColor, pulse); + context.beginPath(); + context.ellipse(center.x, center.y + 10, 24, 8, 0, 0, Math.PI * 2); + context.fill(); + return; + } + + if (tileKind === "d") { + const stairsReady = keyboard.interactionLabel === "stairs down"; + const pulse = keyboard.stairsReached || stairsReady ? 0.3 + Math.sin(elapsed * 8) * 0.06 : 0.08; + const opening = getTileLocalQuad(tileInfo, 0.16, 0.18, 0.84, 0.82); - fillCanvasWithTrail(context, canvas, args.backgroundColor, args.trailOpacity * 0.4); + drawCanvasPolygon(context, opening, "rgba(4, 5, 9, 0.98)", stairsReady ? "rgba(246, 224, 94, 0.52)" : "rgba(0, 0, 0, 0.72)"); - for (let z = 7; z >= -7; z--) { - for (let x = -7; x <= 7; x++) { - const distance = Math.abs(x) + Math.abs(z); - const center = projectIsometricPoint({ x, y: 0, z }, isoOptions); - const tileCorners = getIsometricTileCorners(center, isoOptions); - const isWall = distance > 8 || (x === -2 && z > -5 && z < 4) || (z === 3 && x > -1 && x < 6); - const pulse = 0.55 + Math.sin(elapsed * args.speed + distance) * 0.08; + for (let step = 0; step < 5; step++) { + const v = 0.24 + step * 0.1; + const tread = getTileLocalQuad(tileInfo, 0.23 + step * 0.045, v, 0.77 - step * 0.045, v + 0.055, -step * 3); drawCanvasPolygon( context, - tileCorners, - colorWithAlpha(isWall ? args.secondaryColor : args.accentColor, isWall ? 0.16 : 0.08 + pulse * 0.08), - colorWithAlpha(isWall ? args.secondaryColor : args.accentColor, isWall ? 0.48 : 0.22) + tread, + `rgba(78, 72, 92, ${0.88 - step * 0.1})`, + stairsReady ? "rgba(246, 224, 94, 0.34)" : "rgba(255, 255, 255, 0.09)" ); - - if (isWall) { - drawCanvasPolygon( - context, - getIsometricWallSide(tileCorners, tile), - colorWithAlpha(args.secondaryColor, 0.15) - ); - } + const front = [ + tread[2], + tread[3], + { x: tread[3].x, y: tread[3].y + 5 + step * 2 }, + { x: tread[2].x, y: tread[2].y + 5 + step * 2 }, + ] as ReturnType; + + drawCanvasPolygon(context, front, `rgba(22, 21, 30, ${0.78 - step * 0.08})`); } + context.fillStyle = colorWithAlpha(args.secondaryColor, pulse); + context.beginPath(); + context.ellipse(center.x, center.y + 9, 22, 7, 0, 0, Math.PI * 2); + context.fill(); + return; } - for (let index = 0; index < Math.min(10, args.objectCount); index++) { - const angle = elapsed * args.speed * 0.8 + index * 2.1; - const gridX = Math.round(Math.cos(angle) * 4); - const gridZ = Math.round(Math.sin(angle * 0.7) * 3); - const loot = projectIsometricPoint({ x: gridX, y: 0, z: gridZ }, isoOptions); - const x = loot.x; - const y = loot.y - 18 - bob; + if (tileKind === "r") { + const base = getTileLocalQuad(tileInfo, 0.24, 0.28, 0.74, 0.72, 0); - context.fillStyle = colorWithAlpha(index % 2 === 0 ? args.accentColor : args.secondaryColor, 0.82); - context.beginPath(); - context.moveTo(x, y - 18); - context.lineTo(x + 15, y); - context.lineTo(x, y + 18); - context.lineTo(x - 15, y); - context.closePath(); - context.fill(); - context.strokeStyle = colorWithAlpha("#ffffff", 0.45); - context.stroke(); + drawIsometricCuboid(context, base, 8, { + sideA: "rgba(50, 48, 58, 0.95)", + sideB: "rgba(72, 68, 82, 0.95)", + sideC: "rgba(36, 34, 44, 0.95)", + sideD: "rgba(60, 56, 70, 0.95)", + top: "rgba(99, 92, 112, 0.95)", + stroke: "rgba(255, 255, 255, 0.08)", + }); + drawCanvasPolygon(context, getTileLocalQuad(tileInfo, 0.48, 0.18, 0.8, 0.42), "rgba(83, 79, 95, 0.88)"); + return; } + +}; + +const gradientSafeAddColorStop = ( + gradient: CanvasGradient, + stops: Array<[number, string]> +): void => { + stops.forEach(([offset, color]) => gradient.addColorStop(offset, color)); +}; + +const drawIsometricDungeonPlayer = ( + context: CanvasRenderingContext2D, + isoOptions: Parameters[1], + rotation: number, + camera: IsometricDungeonCamera, + keyboard: KeyboardState, + elapsed: number, + args: Arcade3DStoryArgs +): void => { + const bob = Math.max(0, Math.sin(elapsed * args.speed * 8)) * 2; + const center = projectRotatedIsometricPoint(keyboard.playerX, keyboard.playerZ, rotation, isoOptions, camera); + const bodyBase = getRotatedWorldQuad(keyboard.playerX, keyboard.playerZ, 0.38, 0.38, rotation, isoOptions, camera, bob); + const headBase = getRotatedWorldQuad( + keyboard.playerX, + keyboard.playerZ, + 0.22, + 0.22, + rotation, + isoOptions, + camera, + 19 + bob + ); + const facing = projectRotatedIsometricPoint( + keyboard.playerX + keyboard.facingX * 0.42, + keyboard.playerZ + keyboard.facingZ * 0.42, + rotation, + isoOptions, + camera + ); + const armStart = { x: center.x, y: center.y - 15 + bob }; + const armTarget = { + x: center.x + (facing.x - center.x) * 1.1, + y: center.y - 13 + (facing.y - center.y) * 1.1 + bob, + }; + + context.fillStyle = "rgba(0, 0, 0, 0.34)"; + context.beginPath(); + context.ellipse(center.x, center.y + 10, 12, 5, 0, 0, Math.PI * 2); + context.fill(); + drawIsometricCuboid(context, bodyBase, 20, { + sideA: colorWithAlpha(args.accentColor, 0.74), + sideB: colorWithAlpha(args.accentColor, 0.95), + sideC: colorWithAlpha(args.accentColor, 0.58), + sideD: colorWithAlpha(args.accentColor, 0.82), + top: colorWithAlpha("#90cdf4", 0.96), + stroke: colorWithAlpha("#ffffff", 0.16), + }); + drawCanvasLine(context, armStart, armTarget, colorWithAlpha(args.secondaryColor, 0.9), 3); + drawIsometricCuboid(context, headBase, 8, { + sideA: "#c7a27c", + sideB: "#e8d4b2", + sideC: "#a77d5c", + sideD: "#d8b58f", + top: "#f2dfc3", + stroke: "rgba(5, 7, 10, 0.16)", + }); + context.fillStyle = colorWithAlpha("#05070a", 0.62); + context.fillRect(center.x + Math.sign(facing.x - center.x) * 2, center.y - 24 + bob, 3, 2); }; const drawHyperspaceGate = ( @@ -524,90 +2696,18 @@ const drawHyperspaceGate = ( }; const drawFirstPersonPlayer = ( - { canvas, context, elapsed, pointer }: SceneContext, + { canvas, context, elapsed }: SceneContext, args: Arcade3DStoryArgs ): void => { - const { centerX, horizon } = getFirstPersonCamera( - { height: canvas.height, width: canvas.width }, - { - bobAmount: 6, - bobSpeed: 1.5, - centerDrift: 78, - elapsedSeconds: elapsed, - horizonDrift: 34, - horizonRatio: 0.47, - look: pointer, - speed: args.speed, - } - ); - const viewport = { height: canvas.height, width: canvas.width }; - - fillCanvasWithTrail(context, canvas, args.backgroundColor, args.trailOpacity); - context.fillStyle = colorWithAlpha(args.accentColor, 0.08); - context.fillRect(0, 0, canvas.width, horizon); - context.fillStyle = colorWithAlpha(args.secondaryColor, 0.06); - context.fillRect(0, horizon, canvas.width, canvas.height - horizon); - - for (let index = 0; index < args.objectCount; index++) { - const z = getLoopedDepth({ - depth: args.depth, - elapsedSeconds: elapsed, - index, - offset: 2, - spacing: 1.7, - speed: args.speed * 5, - }); - const progress = getDepthProgress(z, args.depth); - const leftNear = projectPerspectivePoint({ x: -260, y: 220, z }, viewport, { centerX, horizon }); - const rightNear = projectPerspectivePoint({ x: 260, y: 220, z }, viewport, { centerX, horizon }); - const leftFar = projectPerspectivePoint({ x: -260, y: -140, z }, viewport, { centerX, horizon }); - const rightFar = projectPerspectivePoint({ x: 260, y: -140, z }, viewport, { centerX, horizon }); - const alpha = 0.08 + progress * 0.34; - - drawCanvasLine(context, leftNear, leftFar, colorWithAlpha(args.accentColor, alpha), Math.max(1, progress * 5)); - drawCanvasLine(context, rightNear, rightFar, colorWithAlpha(args.accentColor, alpha), Math.max(1, progress * 5)); - drawCanvasLine(context, leftFar, rightFar, colorWithAlpha(args.secondaryColor, alpha * 0.8), Math.max(1, progress * 4)); - - if (index % 3 === 0) { - const marker = projectPerspectivePoint( - { x: Math.sin(index * 2.4) * 150, y: 64, z }, - viewport, - { centerX, horizon } - ); - const size = 34 * marker.scale; - - drawCanvasPolygon( - context, - [ - { x: marker.x, y: marker.y - size }, - { x: marker.x + size, y: marker.y }, - { x: marker.x, y: marker.y + size }, - { x: marker.x - size, y: marker.y }, - ], - colorWithAlpha(index % 2 === 0 ? args.secondaryColor : args.accentColor, 0.18 + progress * 0.5), - colorWithAlpha("#ffffff", 0.22 + progress * 0.2) - ); - } - } - - const reticle = { x: centerX, y: horizon - 4 }; - - drawCanvasLine(context, { x: reticle.x - 24, y: reticle.y }, { x: reticle.x - 6, y: reticle.y }, "#f5f7fb", 2); - drawCanvasLine(context, { x: reticle.x + 6, y: reticle.y }, { x: reticle.x + 24, y: reticle.y }, "#f5f7fb", 2); - drawCanvasLine(context, { x: reticle.x, y: reticle.y - 24 }, { x: reticle.x, y: reticle.y - 6 }, "#f5f7fb", 2); - drawCanvasLine(context, { x: reticle.x, y: reticle.y + 6 }, { x: reticle.x, y: reticle.y + 24 }, "#f5f7fb", 2); - - drawCanvasPolygon( - context, - [ - { x: canvas.width / 2 - 42 + pointer.x * 20, y: canvas.height }, - { x: canvas.width / 2 - 20 + pointer.x * 9, y: canvas.height - 74 + pointer.y * 8 }, - { x: canvas.width / 2 + 20 + pointer.x * 9, y: canvas.height - 74 + pointer.y * 8 }, - { x: canvas.width / 2 + 42 + pointer.x * 20, y: canvas.height }, - ], - colorWithAlpha(args.secondaryColor, 0.54), - colorWithAlpha("#ffffff", 0.36) - ); + drawFpsDemoScene(context, { + height: canvas.height, + intensity: 0.92, + pixelScale: 3, + routeSpeed: args.speed * 1.2, + theme: "sciFi", + timeMs: elapsed * 1000, + width: canvas.width, + }); }; const drawSideScroller2D = ( @@ -1094,13 +3194,18 @@ export const IsometricDungeonRoom: Story = { speed: 0.8, trailOpacity: 0.05, }, - argTypes, + argTypes: isometricDungeonArgTypes, render: (args) => createStoryShell("3D isometric dungeon room", args, drawIsometricDungeon, [ - ["mode", "room"], - ["loot", args.objectCount], - ["camera", "isometric"], - ]), + ["player", "4, 6"], + ["nearby", "none"], + ["camera", "0deg / 1.00x"], + ], { + enableArrowMovement: true, + mapEditor: { initialText: defaultIsometricDungeonMapText }, + enableWheelZoom: true, + updateStats: getIsometricDungeonStats, + }), }; export const HyperspaceGate: Story = { @@ -1136,8 +3241,8 @@ export const FirstPersonPlayer: Story = { render: (args) => createStoryShell("First-person player view", args, drawFirstPersonPlayer, [ ["mode", "first person"], - ["markers", args.objectCount], - ["camera", "player"], + ["scene", "fps corridor"], + ["camera", "route"], ]), }; diff --git a/src/stories/systems/Achievements.stories.ts b/src/stories/systems/Achievements.stories.ts index 4bfc022..5813b8a 100644 --- a/src/stories/systems/Achievements.stories.ts +++ b/src/stories/systems/Achievements.stories.ts @@ -6,7 +6,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Achievements", + title: "Engine/Achievements", } satisfies Meta; export default meta; diff --git a/src/stories/systems/AtmosphericEffects.stories.ts b/src/stories/systems/AtmosphericEffects.stories.ts index 36ebc7e..b163796 100644 --- a/src/stories/systems/AtmosphericEffects.stories.ts +++ b/src/stories/systems/AtmosphericEffects.stories.ts @@ -7,7 +7,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Atmospheric Effects", + title: "Engine/Effects/Atmospheric", } satisfies Meta; export default meta; diff --git a/src/stories/systems/Audio.stories.ts b/src/stories/systems/Audio.stories.ts index 280bf2b..5b2127b 100644 --- a/src/stories/systems/Audio.stories.ts +++ b/src/stories/systems/Audio.stories.ts @@ -3,7 +3,7 @@ import type { Meta } from "@storybook/html-vite"; import { SpatialAudioMath as SpatialAudioMathStory } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Audio", + title: "Engine/Audio/Spatial Audio", } satisfies Meta; export default meta; diff --git a/src/stories/systems/ComboEffects.stories.ts b/src/stories/systems/ComboEffects.stories.ts index 5545f91..9ba7b83 100644 --- a/src/stories/systems/ComboEffects.stories.ts +++ b/src/stories/systems/ComboEffects.stories.ts @@ -7,7 +7,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Combo Effects", + title: "Engine/Effects/Combos", } satisfies Meta; export default meta; diff --git a/src/stories/systems/EnvironmentScreenEffects.stories.ts b/src/stories/systems/EnvironmentScreenEffects.stories.ts index 99f020e..5014069 100644 --- a/src/stories/systems/EnvironmentScreenEffects.stories.ts +++ b/src/stories/systems/EnvironmentScreenEffects.stories.ts @@ -8,7 +8,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Screen Effects", + title: "Engine/Effects/Environment", } satisfies Meta; export default meta; diff --git a/src/stories/systems/Input.stories.ts b/src/stories/systems/Input.stories.ts index 449c50a..bda25b6 100644 --- a/src/stories/systems/Input.stories.ts +++ b/src/stories/systems/Input.stories.ts @@ -3,7 +3,7 @@ import type { Meta } from "@storybook/html-vite"; import { InputActions as InputActionsStory, LocalMultiplayer as LocalMultiplayerStory } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Input", + title: "Engine/Input", } satisfies Meta; export default meta; diff --git a/src/stories/systems/Physics.stories.ts b/src/stories/systems/Physics.stories.ts index 6e962de..e0fe2e4 100644 --- a/src/stories/systems/Physics.stories.ts +++ b/src/stories/systems/Physics.stories.ts @@ -7,7 +7,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Physics", + title: "Engine/Physics", } satisfies Meta; export default meta; diff --git a/src/stories/systems/PlayerData.stories.ts b/src/stories/systems/PlayerData.stories.ts index a4c56fc..ab1efc1 100644 --- a/src/stories/systems/PlayerData.stories.ts +++ b/src/stories/systems/PlayerData.stories.ts @@ -3,7 +3,7 @@ import type { Meta } from "@storybook/html-vite"; import { HighScores as HighScoresStory, UserOptions as UserOptionsStory } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Player Data", + title: "Engine/Player Data", } satisfies Meta; export default meta; diff --git a/src/stories/systems/Presentation.stories.ts b/src/stories/systems/Presentation.stories.ts index 3eb59b5..22ad154 100644 --- a/src/stories/systems/Presentation.stories.ts +++ b/src/stories/systems/Presentation.stories.ts @@ -3,15 +3,17 @@ import type { Meta } from "@storybook/html-vite"; import { DisplayFilters as DisplayFiltersStory, ProceduralStars as ProceduralStarsStory, + RayTracedApartment as RayTracedApartmentStory, SpriteAnimationAndCamera as SpriteAnimationAndCameraStory, } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Presentation", + title: "Engine/Rendering", } satisfies Meta; export default meta; export const DisplayFilters = DisplayFiltersStory; export const ProceduralStars = ProceduralStarsStory; +export const RayTracedApartment = RayTracedApartmentStory; export const SpriteAnimationAndCamera = SpriteAnimationAndCameraStory; diff --git a/src/stories/systems/README.md b/src/stories/systems/README.md index da33588..505a111 100644 --- a/src/stories/systems/README.md +++ b/src/stories/systems/README.md @@ -1,7 +1,16 @@ -# ๐Ÿงฉ Systems Stories +# ๐Ÿงฉ Shared Feature Stories -This folder documents engine helpers that coordinate common game systems rather -than a single geometry or drawing operation. +This folder contains shared story implementations for several Storybook sidebar +sections. The visible Storybook groups are feature-oriented rather than nested +under a broad "Systems" bucket: + +- `Engine/Input` +- `Engine/Player Data` +- `Engine/Achievements` +- `Engine/Rendering` +- `Engine/Effects` +- `Engine/Physics` +- `Engine/Audio/Spatial Audio` ## ๐ŸŽฎ Input Actions @@ -84,6 +93,29 @@ The story demonstrates: Use this pattern for space, sky, hyperspace, asteroid-field, or cloud-like background prop layers where the renderer owns the final art style. +## ๐Ÿ’ก Ray-Traced Apartment + +`RayTracedApartment` shows 2D ray-traced lighting in a top-down apartment scene. + +The story demonstrates: + +- `traceVisibilityPolygon`. +- `traceLightBounces`. +- `createRayTracingRectangle`. +- A blue window light, warm lamp light, and monochrome TV-static light. +- Separate intensity controls for the window, lamp, and TV. +- A bounce-count control capped to three, with one bounce enabled by default. +- A bounce attenuation control for tuning how quickly indirect light drops off. +- A light ray guide toggle for hiding or showing direct visibility edges. +- Material-tinted bounces from furniture and room-boundary surface colors. +- Draggable room objects, including the sofa, coffee table, TV, plant, shelf, + rug, and lamp. +- Live occluder rebuilding so moved furniture changes the light and shadows. + +Use this pattern for Canvas 2D lighting, line-of-sight previews, vision cones, +fog-of-war masks, or stealth visibility where the renderer owns the final color +and blend mode. + ## ๐Ÿ† Achievements `Achievements` shows local achievement definition, progress, unlock, and status diff --git a/src/stories/systems/ScreenEffects.stories.ts b/src/stories/systems/ScreenEffects.stories.ts index 4ec4523..8b1d4db 100644 --- a/src/stories/systems/ScreenEffects.stories.ts +++ b/src/stories/systems/ScreenEffects.stories.ts @@ -11,7 +11,7 @@ import { } from "./systems-demos.js"; const meta = { - title: "Engine/Systems/Player Effects", + title: "Engine/Effects/Player", } satisfies Meta; export default meta; diff --git a/src/stories/systems/systems-demos.ts b/src/stories/systems/systems-demos.ts index 1efd65c..ce6b1d5 100644 --- a/src/stories/systems/systems-demos.ts +++ b/src/stories/systems/systems-demos.ts @@ -34,6 +34,8 @@ import { screenPoisonEffectId, screenShockEffectId, screenSpeedBoostEffectId, + colorWithAlpha, + createRayTracingRectangle, drawCanvasLine, drawCanvasPolygon, fillCanvasWithTrail, @@ -64,6 +66,12 @@ import { type RagdollConstraint, type RagdollPoint2D, type RagdollPoint3D, + type RayTracingBounce, + type RayTracingBounds, + type RayTracingPoint, + type RayTracingPolygon, + type RayTracingSurface, + traceLightBounces, } from "../../index.js"; import { appendStyles, @@ -85,6 +93,10 @@ type SystemsStoryArgs = { displayFilterMode?: DisplayFilterMode; displayFilterBoost?: number; highScoreValue?: number; + bounceAttenuation?: number; + lampLightIntensity?: number; + lightBounces?: number; + showLightRayGuides?: boolean; lowScoreValue?: number; maxAcceptedScore?: number; onAchievementProgress?: (id: DemoAchievementId) => void; @@ -132,7 +144,9 @@ type SystemsStoryArgs = { ashEmberMotionPreset?: AtmosphericMotionPreset; onUserOptionsChange?: (options: Record) => void; precisionGoal?: number; + tvLightIntensity?: number; waveGoal?: number; + windowLightIntensity?: number; }; type Story = StoryObj; @@ -2711,3 +2725,686 @@ export const Ragdoll3D: Story = { return shell; }, }; + +type ApartmentLight = { + color: string; + id: "lamp" | "tv" | "window"; + phase: number; + position: RayTracingPoint; + radius: number; + strength: number; + temperature: string; +}; + +type ApartmentObjectKind = + | "coffee-table" + | "console" + | "lamp" + | "plant" + | "rug" + | "shelf" + | "sofa"; + +type ApartmentObject = { + blocksLight: boolean; + fill: string; + height: number; + id: string; + kind: ApartmentObjectKind; + movable: boolean; + stroke?: string; + width: number; + x: number; + y: number; +}; + +type ApartmentDrag = { + index: number; + offsetX: number; + offsetY: number; +}; + +const apartmentBounds: RayTracingBounds = { + height: 332, + surfaceColor: "#2b3035", + width: 612, + x: 54, + y: 34, +}; + +const createApartmentObjects = (): ApartmentObject[] => [ + { + blocksLight: true, + fill: "#343945", + height: 92, + id: "sofa", + kind: "sofa", + movable: true, + width: 192, + x: 166, + y: 212, + }, + { + blocksLight: true, + fill: "#5f4a38", + height: 62, + id: "coffee table", + kind: "coffee-table", + movable: true, + stroke: "rgba(255, 236, 180, 0.2)", + width: 120, + x: 256, + y: 170, + }, + { + blocksLight: true, + fill: "#544437", + height: 74, + id: "lamp", + kind: "lamp", + movable: true, + stroke: "rgba(255, 222, 142, 0.2)", + width: 60, + x: 468, + y: 202, + }, + { + blocksLight: true, + fill: "#101913", + height: 30, + id: "tv", + kind: "console", + movable: true, + stroke: "rgba(94, 255, 122, 0.28)", + width: 138, + x: 422, + y: 68, + }, + { + blocksLight: true, + fill: "#423f37", + height: 40, + id: "shelf", + kind: "shelf", + movable: true, + stroke: "rgba(245, 247, 251, 0.12)", + width: 116, + x: 102, + y: 68, + }, + { + blocksLight: true, + fill: "#29352d", + height: 50, + id: "plant", + kind: "plant", + movable: true, + stroke: "rgba(144, 205, 244, 0.12)", + width: 52, + x: 84, + y: 296, + }, + { + blocksLight: false, + fill: "rgba(245, 247, 251, 0.09)", + height: 56, + id: "rug", + kind: "rug", + movable: true, + stroke: "rgba(245, 247, 251, 0.06)", + width: 74, + x: 376, + y: 244, + }, +]; + +const drawApartmentPath = ( + context: CanvasRenderingContext2D, + points: readonly RayTracingPoint[] +): void => { + context.beginPath(); + points.forEach((point, index) => { + if (index === 0) { + context.moveTo(point.x, point.y); + return; + } + + context.lineTo(point.x, point.y); + }); + context.closePath(); +}; + +const fillApartmentRectangle = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + fill: string, + stroke = "rgba(245, 247, 251, 0.14)" +): void => { + context.fillStyle = fill; + context.fillRect(x, y, width, height); + context.strokeStyle = stroke; + context.lineWidth = 2; + context.strokeRect(x, y, width, height); +}; + +const getApartmentObjectPolygon = (object: ApartmentObject): RayTracingPolygon => + createRayTracingRectangle(object.x, object.y, object.width, object.height); + +const getApartmentObjectCenter = (object: ApartmentObject): RayTracingPoint => ({ + x: object.x + object.width / 2, + y: object.y + object.height / 2, +}); + +const getApartmentOccluders = ( + objects: readonly ApartmentObject[], + origin?: RayTracingPoint +): RayTracingSurface[] => + objects + .filter( + (object) => + object.blocksLight && + (!origin || + origin.x < object.x || + origin.x > object.x + object.width || + origin.y < object.y || + origin.y > object.y + object.height) + ) + .map((object) => ({ + polygon: getApartmentObjectPolygon(object), + surfaceColor: object.fill, + })); + +const getApartmentObjectById = ( + objects: readonly ApartmentObject[], + id: string +): ApartmentObject | undefined => objects.find((object) => object.id === id); + +const getTvStaticColor = (frame: number): string => { + const colors = ["#d9dde5", "#c7ced8", "#e6e9ef", "#b9c1cc", "#d0d6df", "#edf0f5"]; + const step = Math.floor(frame / 8); + const jitter = Math.floor(Math.abs(Math.sin(frame * 0.37) * 2)); + + return colors[(step + jitter) % colors.length] ?? "#f5f7fb"; +}; + +const getApartmentLights = ( + objects: readonly ApartmentObject[], + frame: number +): ApartmentLight[] => { + const lamp = getApartmentObjectById(objects, "lamp"); + const tv = getApartmentObjectById(objects, "tv"); + + return [ + { + color: "#7ec8ff", + id: "window", + phase: 0, + position: { x: 78, y: 136 }, + radius: 340, + strength: 0.36, + temperature: "cool blue", + }, + { + color: "#ffd36f", + id: "lamp", + phase: 1.6, + position: lamp ? getApartmentObjectCenter(lamp) : { x: 498, y: 216 }, + radius: 230, + strength: 0.42, + temperature: "warm yellow", + }, + { + color: getTvStaticColor(frame), + id: "tv", + phase: 3.1, + position: tv + ? { x: tv.x + tv.width / 2, y: tv.y + tv.height + 6 } + : { x: 492, y: 104 }, + radius: 260, + strength: 0.54, + temperature: "static flicker", + }, + ]; +}; + +const drawApartmentObject = ( + context: CanvasRenderingContext2D, + object: ApartmentObject, + isDragging: boolean +): void => { + fillApartmentRectangle( + context, + object.x, + object.y, + object.width, + object.height, + object.fill, + isDragging ? "rgba(245, 247, 251, 0.72)" : object.stroke + ); + + if (object.kind === "sofa") { + fillApartmentRectangle( + context, + object.x + 18, + object.y + 20, + 68, + 48, + "#48505f", + "rgba(245, 247, 251, 0.11)" + ); + fillApartmentRectangle( + context, + object.x + object.width - 86, + object.y + 20, + 68, + 48, + "#48505f", + "rgba(245, 247, 251, 0.11)" + ); + } + + if (object.kind === "coffee-table") { + fillApartmentRectangle( + context, + object.x + 30, + object.y + 18, + 60, + 24, + "#7a6048", + "rgba(255, 236, 180, 0.2)" + ); + } + + if (object.kind === "console") { + context.fillStyle = "rgba(245, 247, 251, 0.68)"; + context.fillRect(object.x + 22, object.y + 8, object.width - 44, 10); + context.fillStyle = "rgba(255, 255, 255, 0.46)"; + context.fillRect(object.x + 22, object.y + 8, object.width - 62, 4); + context.fillStyle = "rgba(148, 163, 184, 0.44)"; + context.fillRect(object.x + 38, object.y + 14, object.width - 66, 3); + } + + if (object.kind === "lamp") { + const center = getApartmentObjectCenter(object); + + context.fillStyle = "rgba(255, 217, 116, 0.78)"; + context.beginPath(); + context.arc(center.x, center.y, 12, 0, Math.PI * 2); + context.fill(); + } +}; + +const drawApartmentBase = ( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + objects: readonly ApartmentObject[], + draggingIndex?: number +): void => { + context.fillStyle = "#07080b"; + context.fillRect(0, 0, canvas.width, canvas.height); + + const floor = context.createLinearGradient( + apartmentBounds.x ?? 0, + apartmentBounds.y ?? 0, + (apartmentBounds.x ?? 0) + apartmentBounds.width, + (apartmentBounds.y ?? 0) + apartmentBounds.height + ); + floor.addColorStop(0, "#22201d"); + floor.addColorStop(0.5, "#181b1e"); + floor.addColorStop(1, "#1d1b20"); + context.fillStyle = floor; + context.fillRect( + apartmentBounds.x ?? 0, + apartmentBounds.y ?? 0, + apartmentBounds.width, + apartmentBounds.height + ); + + context.strokeStyle = "rgba(245, 247, 251, 0.26)"; + context.lineWidth = 10; + context.strokeRect( + apartmentBounds.x ?? 0, + apartmentBounds.y ?? 0, + apartmentBounds.width, + apartmentBounds.height + ); + + context.strokeStyle = "rgba(120, 190, 255, 0.85)"; + context.lineWidth = 8; + context.beginPath(); + context.moveTo(58, 96); + context.lineTo(58, 178); + context.stroke(); + + context.strokeStyle = "rgba(246, 224, 94, 0.18)"; + context.lineWidth = 1; + for (let x = 80; x < 650; x += 32) { + drawCanvasLine(context, { x, y: 44 }, { x, y: 356 }, "rgba(245, 247, 251, 0.035)"); + } + for (let y = 58; y < 350; y += 32) { + drawCanvasLine(context, { x: 64, y }, { x: 656, y }, "rgba(245, 247, 251, 0.032)"); + } + + objects.forEach((object, index) => { + if (object.kind === "rug") { + drawApartmentObject(context, object, index === draggingIndex); + } + }); + objects.forEach((object, index) => { + if (object.kind !== "rug") { + drawApartmentObject(context, object, index === draggingIndex); + } + }); +}; + +const drawApartmentLightLayer = ( + context: CanvasRenderingContext2D, + light: ApartmentLight, + layer: RayTracingBounce, + strength: number +): void => { + const isBounce = layer.level > 0; + const radius = isBounce ? light.radius * 0.32 : light.radius; + const gradient = context.createRadialGradient( + layer.origin.x, + layer.origin.y, + 2, + layer.origin.x, + layer.origin.y, + radius + ); + const color = layer.color ?? light.color; + const alpha = isBounce ? strength * layer.intensity * 0.58 : strength * layer.intensity; + + gradient.addColorStop(0, colorWithAlpha(color, alpha)); + gradient.addColorStop(0.46, colorWithAlpha(color, alpha * 0.32)); + gradient.addColorStop(1, colorWithAlpha(color, 0)); + + context.save(); + if (isBounce) { + context.beginPath(); + context.rect( + apartmentBounds.x ?? 0, + apartmentBounds.y ?? 0, + apartmentBounds.width, + apartmentBounds.height + ); + } else { + drawApartmentPath(context, layer.hits); + } + context.clip(); + context.globalCompositeOperation = "lighter"; + context.fillStyle = gradient; + context.fillRect( + (apartmentBounds.x ?? 0) - 8, + (apartmentBounds.y ?? 0) - 8, + apartmentBounds.width + 16, + apartmentBounds.height + 16 + ); + context.restore(); +}; + +const drawApartmentLight = ( + context: CanvasRenderingContext2D, + light: ApartmentLight, + frame: number, + occluders: readonly RayTracingSurface[], + intensity: number, + bounces: number, + showRayGuides: boolean, + bounceAttenuation: number +): RayTracingBounce[] => { + const pulse = 0.9 + Math.sin(frame / 42 + light.phase) * 0.08; + const strength = light.strength * intensity * pulse; + const layers = traceLightBounces(light.position, apartmentBounds, occluders, { + attenuation: bounceAttenuation, + bounces, + lightColor: light.color, + maxOriginsPerBounce: 3, + surfaceColorMix: 0.42, + }); + + layers.forEach((layer) => drawApartmentLightLayer(context, light, layer, strength)); + + if (showRayGuides) { + context.save(); + drawApartmentPath(context, layers[0]?.hits ?? []); + context.strokeStyle = colorWithAlpha(light.color, 0.18); + context.lineWidth = 1; + context.stroke(); + context.restore(); + } + + return layers; +}; + +const drawApartmentFixtures = ( + context: CanvasRenderingContext2D, + lights: readonly ApartmentLight[] +): void => { + context.save(); + context.globalCompositeOperation = "source-over"; + lights.forEach((light) => { + context.fillStyle = colorWithAlpha(light.color, 0.92); + context.beginPath(); + context.arc(light.position.x, light.position.y, 7, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = colorWithAlpha("#ffffff", 0.68); + context.lineWidth = 2; + context.stroke(); + }); + context.restore(); +}; + +const getApartmentPointerPoint = ( + canvas: HTMLCanvasElement, + event: PointerEvent +): RayTracingPoint => { + const bounds = canvas.getBoundingClientRect(); + + return { + x: ((event.clientX - bounds.left) / bounds.width) * canvas.width, + y: ((event.clientY - bounds.top) / bounds.height) * canvas.height, + }; +}; + +const findApartmentObjectAt = ( + objects: readonly ApartmentObject[], + point: RayTracingPoint +): number => { + for (let index = objects.length - 1; index >= 0; index -= 1) { + const object = objects[index]; + + if ( + object?.movable && + point.x >= object.x && + point.x <= object.x + object.width && + point.y >= object.y && + point.y <= object.y + object.height + ) { + return index; + } + } + + return -1; +}; + +const moveApartmentObject = ( + object: ApartmentObject, + point: RayTracingPoint, + drag: ApartmentDrag +): void => { + const left = apartmentBounds.x ?? 0; + const top = apartmentBounds.y ?? 0; + const maxX = left + apartmentBounds.width - object.width; + const maxY = top + apartmentBounds.height - object.height; + + object.x = Math.max(left, Math.min(maxX, point.x - drag.offsetX)); + object.y = Math.max(top, Math.min(maxY, point.y - drag.offsetY)); +}; + +export const RayTracedApartment: Story = { + args: { + bounceAttenuation: 0.1, + lampLightIntensity: 0.65, + lightBounces: 1, + showLightRayGuides: true, + tvLightIntensity: 0.65, + windowLightIntensity: 0.65, + }, + argTypes: { + bounceAttenuation: { control: { max: 0.5, min: 0.02, step: 0.02, type: "range" } }, + lampLightIntensity: { control: { max: 1.2, min: 0.05, step: 0.05, type: "range" } }, + lightBounces: { control: { max: 3, min: 0, step: 1, type: "range" } }, + showLightRayGuides: { control: "boolean" }, + tvLightIntensity: { control: { max: 1.2, min: 0.05, step: 0.05, type: "range" } }, + windowLightIntensity: { control: { max: 1.2, min: 0.05, step: 0.05, type: "range" } }, + }, + render: (args) => { + const { canvas, metrics, shell } = createSystemsLayout("Ray-traced apartment"); + const context = canvas.getContext("2d"); + const windowValue = createValue("window", "blue cool"); + const lampValue = createValue("lamp", "warm yellow"); + const tvValue = createValue("tv", "static flicker"); + const raysValue = createValue("rays"); + const bouncesValue = createValue("bounces", args.lightBounces ?? 1); + const bounceAttenuationValue = createValue("bounce attenuation", args.bounceAttenuation ?? 0.1); + const guidesValue = createValue("guides", args.showLightRayGuides ? "on" : "off"); + const selectedValue = createValue("selected", "none"); + const windowIntensityValue = createValue("window intensity", args.windowLightIntensity ?? 0.65); + const lampIntensityValue = createValue("lamp intensity", args.lampLightIntensity ?? 0.65); + const tvIntensityValue = createValue("tv intensity", args.tvLightIntensity ?? 0.65); + const objects = createApartmentObjects(); + let drag: ApartmentDrag | undefined; + let animationFrame = 0; + let frame = 0; + + resizeCanvas(canvas, 720, 420); + canvas.style.cursor = "grab"; + metrics.append( + windowValue, + lampValue, + tvValue, + raysValue, + bouncesValue, + bounceAttenuationValue, + guidesValue, + selectedValue, + windowIntensityValue, + lampIntensityValue, + tvIntensityValue, + createValue("movable", String(objects.filter((object) => object.movable).length)) + ); + + const pointerDown = (event: PointerEvent): void => { + const point = getApartmentPointerPoint(canvas, event); + const index = findApartmentObjectAt(objects, point); + const object = objects[index]; + + if (!object) { + setValue(selectedValue, "none"); + return; + } + + drag = { + index, + offsetX: point.x - object.x, + offsetY: point.y - object.y, + }; + canvas.setPointerCapture(event.pointerId); + canvas.style.cursor = "grabbing"; + setValue(selectedValue, object.id); + }; + + const pointerMove = (event: PointerEvent): void => { + const point = getApartmentPointerPoint(canvas, event); + + if (!drag) { + canvas.style.cursor = + findApartmentObjectAt(objects, point) >= 0 ? "grab" : "default"; + return; + } + + const object = objects[drag.index]; + + if (object) { + moveApartmentObject(object, point, drag); + setValue(selectedValue, object.id); + } + }; + + const pointerUp = (event: PointerEvent): void => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + + drag = undefined; + canvas.style.cursor = "grab"; + }; + + canvas.addEventListener("pointerdown", pointerDown); + canvas.addEventListener("pointermove", pointerMove); + canvas.addEventListener("pointerup", pointerUp); + canvas.addEventListener("pointercancel", pointerUp); + + const render = (): void => { + if (!context) { + return; + } + + frame += 1; + const lights = getApartmentLights(objects, frame); + const intensityByLight = { + lamp: Math.max(0, Math.min(1.5, args.lampLightIntensity ?? 0.65)), + tv: Math.max(0, Math.min(1.5, args.tvLightIntensity ?? 0.65)), + window: Math.max(0, Math.min(1.5, args.windowLightIntensity ?? 0.65)), + }; + const bounces = Math.max(0, Math.min(3, Math.floor(args.lightBounces ?? 1))); + const bounceAttenuation = Math.max(0.02, Math.min(0.5, args.bounceAttenuation ?? 0.1)); + const showRayGuides = args.showLightRayGuides ?? true; + drawApartmentBase(context, canvas, objects, drag?.index); + const hitCount = lights.reduce( + (count, light) => + count + + drawApartmentLight( + context, + light, + frame, + getApartmentOccluders(objects, light.position), + intensityByLight[light.id], + bounces, + showRayGuides, + bounceAttenuation + ).reduce((total, layer) => total + layer.hits.length, 0), + 0 + ); + drawApartmentFixtures(context, lights); + + setValue(windowValue, lights[0]?.temperature ?? ""); + setValue(lampValue, lights[1]?.temperature ?? ""); + setValue(tvValue, lights[2]?.temperature ?? ""); + setValue(raysValue, hitCount); + setValue(bouncesValue, bounces); + setValue(bounceAttenuationValue, bounceAttenuation.toFixed(2)); + setValue(guidesValue, showRayGuides ? "on" : "off"); + setValue(windowIntensityValue, intensityByLight.window.toFixed(2)); + setValue(lampIntensityValue, intensityByLight.lamp.toFixed(2)); + setValue(tvIntensityValue, intensityByLight.tv.toFixed(2)); + animationFrame = window.requestAnimationFrame(render); + }; + + animationFrame = window.requestAnimationFrame(render); + onRemove(shell, () => { + window.cancelAnimationFrame(animationFrame); + canvas.removeEventListener("pointerdown", pointerDown); + canvas.removeEventListener("pointermove", pointerMove); + canvas.removeEventListener("pointerup", pointerUp); + canvas.removeEventListener("pointercancel", pointerUp); + }); + + return shell; + }, +}; diff --git a/src/string-tile-map.ts b/src/string-tile-map.ts new file mode 100644 index 0000000..b6c27b2 --- /dev/null +++ b/src/string-tile-map.ts @@ -0,0 +1,110 @@ +export type StringTileMapCell = { + column: number; + row: number; + tile: TTile; + x: number; + z: number; +}; + +export type StringTileMap = { + height: number; + rows: TTile[][]; + text: string; + width: number; +}; + +export type ParseStringTileMapOptions = { + emptyTile?: TTile; + normalizeTile?: (tile: string) => TTile; +}; + +export const parseStringTileMap = ( + text: string, + options: ParseStringTileMapOptions = {} +): StringTileMap => { + const emptyTile = options.emptyTile ?? (" " as TTile); + const normalizeTile = options.normalizeTile ?? ((tile: string) => tile as TTile); + const sourceRows = text + .replace(/\t/g, " ") + .split("\n") + .map((row) => row.replace(/\s+$/u, "")) + .filter((row) => row.length > 0); + const rows = sourceRows.length > 0 ? sourceRows : [""]; + const width = Math.max(...rows.map((row) => row.length), 1); + const parsedRows = rows.map((row) => + Array.from({ length: width }, (_, column) => { + const tile = row[column]; + + return tile === undefined ? emptyTile : normalizeTile(tile); + }) + ); + + return { + height: parsedRows.length, + rows: parsedRows, + text, + width, + }; +}; + +export const getStringTileMapTile = ( + map: StringTileMap, + column: number, + row: number +): TTile | undefined => map.rows[row]?.[column]; + +export const getStringTileMapCenteredPoint = ( + map: StringTileMap, + column: number, + row: number +): { x: number; z: number } => ({ + x: column - Math.floor(map.width / 2), + z: row - Math.floor(map.height / 2), +}); + +export const getStringTileMapCellFromCenteredPoint = ( + map: StringTileMap, + x: number, + z: number +): StringTileMapCell | undefined => { + const column = Math.floor(x + Math.floor(map.width / 2)); + const row = Math.floor(z + Math.floor(map.height / 2)); + const tile = getStringTileMapTile(map, column, row); + + if (tile === undefined) { + return undefined; + } + + return { + column, + row, + tile, + ...getStringTileMapCenteredPoint(map, column, row), + }; +}; + +export const findStringTileMapCells = ( + map: StringTileMap, + tile: TTile +): Array> => + map.rows.flatMap((rowTiles, row) => + rowTiles.flatMap((cellTile, column) => { + if (cellTile !== tile) { + return []; + } + + return [ + { + column, + row, + tile: cellTile, + ...getStringTileMapCenteredPoint(map, column, row), + }, + ]; + }) + ); + +export const findStringTileMapCell = ( + map: StringTileMap, + tile: TTile +): StringTileMapCell | undefined => findStringTileMapCells(map, tile)[0];