diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index 5ef17b5c..5e32eb46 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -48,6 +48,7 @@ devicerestored diegetic dppx eamodio +enterkeyhint Erdokovy esbenp evented @@ -83,6 +84,7 @@ implementability impluse Infima inigo +inputmode interactable interactables isampler @@ -101,6 +103,7 @@ letterboxing lifecycles lowp matterjs +maxlength mediump Menlo mesher diff --git a/AGENTS.md b/AGENTS.md index 0a01785a..da64fe77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ this step by step for bug fixes. /ecs # Entity-Component-System core /events # Event system /finite-state-machine # FSM implementation - /input # Input handling + /input # Input handling (keyboard, mouse, gamepad, hidden-DOM text entry) /lifecycle # Lifecycle management /math # Math utilities /particles # Particle system @@ -116,7 +116,7 @@ this step by step for bug fixes. /storage # StorageBackend (localStorage, memory) and createPersistentState: typed records kept outside the game /text # MSDF font atlas loading and text rendering /timer # Timer utilities - /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, layout groups, content size/aspect ratio fitters) + /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, text inputs, layout groups, content size/aspect ratio fitters) /utilities # General utilities index.ts # Main exports diff --git a/CHANGELOG.md b/CHANGELOG.md index d02d1e60..50e881d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))` - **text:** Rich text tags. `TextEcsComponent.text` (and `shapeText`) parse `...` and `...` (`#rgb`, `#rgba` and `#rrggbbaa` too) to style part of a string. Tags are stripped before shaping, so they never change kerning or wrapping. `` draws a synthetic bold from the same atlas, thickening each glyph by `FAUX_BOLD_EMBOLDEN` ems per side and widening its advance to match. A `` replaces the text's `color`, alpha included. Markup that isn't a valid tag, such as `HP < 50%` or ``, is drawn as literal text, so a string that happens to contain `` or `` now renders styled. `parseRichText` is public, and `GlyphQuad` gains `color` and `embolden` - **text:** The engine's default font can be imported through a bundler from `@forge-game-engine/forge/fonts/default/default.json` and `@forge-game-engine/forge/fonts/default/default.png`, so you no longer need to copy it out of `node_modules` +- **ui:** Text input fields. `createTextInput(world, parent, { renderContext, sprite, fillSprite, fontAtlas, size })` builds a single-line field the player types into through a hidden DOM ``, so keyboard layouts, IME composition, paste, phone keyboards and screen readers work. Its `TextInputEcsComponent` holds the filtered `value` (characters the font can't draw are dropped, then an optional `filter` and `maxLength` apply), `isEditing`, and `onValueChanged`/`onSubmit`/`onCancel` events; `setTextInputValue` and `editTextInput` change it from code. Editing is separate from UI focus: it starts on a click, a tap (focusing inside the tap, so phone keyboards open) or `submitInput` while focused, and ends on Enter, Escape or blur. `registerUiSystems` registers the new `createUiTextInputEcsSystem` +- **input:** `createTextEntry(container)` creates the hidden-input primitive text fields are built on, for game-drawn text that isn't a UI field (a chat box, a debug console): it exposes the value, selection and IME composition, and reports Enter/Escape/changes/blur through `takeEvents()` +- **text:** `TextEcsComponent.richText` (and `shapeText`'s `richText` option), default `true`. Set it to `false` to draw text exactly as written, tags included, for text a player typed. Text fields' labels set it to `false` +- **text:** `shapeText` returns `caretStops`, the position of every UTF-16 boundary in the shaped text (whitespace and missing glyphs included), and `TextMeshEcsComponent.caretStops` carries them +- **ui:** `raycastUiCanvas(world, canvasEntity, renderContext, viewportPosition)` returns the topmost interactable under a pointer position on a canvas, the same hit test `createUiRaycastEcsSystem` runs each tick #### Changed @@ -42,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ui:** A `'screenPixels'`-unit size or margin, and `UiSafeAreaEcsComponent` insets, now follow the canvas camera's `zoom`, so they keep their on-screen size on a world-space canvas whose camera zooms. A screen-space canvas's root rect now fills its camera's view, so it follows a moved or zoomed UI camera - **rendering:** Render targets can be canvas-sized. `createRenderTarget(renderContext, size, format?)` takes the render context instead of `gl`, and a `size` that's either `'canvas'` or a fixed `{ width, height }`. A canvas-sized target is resized by `RenderContext.resize` along with the canvas, so a camera's target always matches it: replace `createRenderTarget(renderContext.gl, renderContext.width, renderContext.height)` with `createRenderTarget(renderContext, 'canvas')` and delete any code that resized camera targets to follow the canvas. `RenderTarget.resize` throws for a canvas-sized target. `RenderTarget`'s `swapBuffers()`, `resize(width, height)` and `dispose()` no longer take `gl`, and `width`/`height` are read-only. `PingPongTarget` changes the same way: `new PingPongTarget(renderContext, size, format?)`, `resize(width, height)` and `dispose()`. The render context keeps a reference to each canvas-sized target until it's disposed, so `dispose()` targets you stop using. Screen-space UI canvases now render into a canvas-sized target, and `createUiLayoutEcsSystem` no longer resizes their camera's target. `RenderContext.maxPixelRatio` can be set at runtime, for example from a graphics quality setting: it re-applies the last resize at the new cap (or is stored for the next resize while the canvas has no size), so remove any cast you used to write it and the `resize` call that followed. `RenderContext`'s `width`, `height`, `cssWidth`, `cssHeight` and `pixelRatio` are now read-only, changed only by `resize` - **text:** `FontAtlasCache.getOrLoad` takes the URL of both atlas files, `getOrLoad({ metricsUrl, imageUrl })`, instead of finding the image next to the JSON, so atlases imported through Vite, webpack or another bundler that renames files now load. Pass the URLs your bundler gives you for the `.json` and `.png` (with Vite, `import metricsUrl from './my-font.json?url'` and `import imageUrl from './my-font.png'`), and look loaded atlases up with `get(metricsUrl)`. `getOrLoad` rejects if the image's size doesn't match the JSON's `atlasSize`, or if one `metricsUrl` is requested with two different image URLs. Concurrent calls for the same atlas now share one load. `FontAtlasCache` no longer implements `AssetCache` and its `load` method and `assets` map are no longer public; call `getOrLoad` instead. `FontAtlasData` and `FontAtlasFileData` no longer have an `atlasImage` field and `forge-generate-font-atlas` no longer writes one; existing JSON files that have it still load +- **ui:** `resolveCanvasPointerPosition` takes the pointer's viewport position (CSS pixels, Y-down) as its last argument instead of a `UiPointerSource`. Pass `pointerSource.position` - **rendering:** Bloom, Gaussian blur and tone mapping no longer copy their result back into the camera's render target, saving one full-screen draw per effect per frame and a full-resolution scratch buffer each. A `RenderTarget` now has two color buffers: the new `beginPostProcessPass(renderContext, target)` makes the other one current, binds and clears it, and returns the texture holding the target's contents, so a custom screen effect is that call plus `drawFullscreenQuad` with a material that samples the returned texture (see "Writing a post-processing effect" in the Multipass Rendering guide). Delete your effect's scratch target, copy material and copy-back draw. `RenderTarget.colorTexture` and `RenderTarget.framebuffer` are now read-only getters that change whenever a post-processing pass runs on the target, so read them when you draw instead of keeping them from setup. Bloom with `passes: 0` now draws nothing, like the blur, instead of adding its unblurred highlights; set `passes` to `0` to turn bloom off without changing `intensity` #### Removed @@ -52,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Fixed +- **input:** `KeyboardInputSource` ignores keys typed into an editable element (``, `