From 0eff4e97470c63c756e86468850d362a8d224c60 Mon Sep 17 00:00:00 2001 From: Chad Neff Date: Sat, 6 Dec 2025 15:37:23 -0800 Subject: [PATCH 1/5] add llms with some primers - add initial llms.txt to @moxijs/core and @moxijs/ui - include quick start, install, and example usage as primers for both packages --- packages/core/llms.txt | 213 +++++++++++++++++++++++++++++ packages/core/package.json | 3 +- packages/ui/llms.txt | 273 +++++++++++++++++++++++++++++++++++++ packages/ui/package.json | 3 +- 4 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 packages/core/llms.txt create mode 100644 packages/ui/llms.txt diff --git a/packages/core/llms.txt b/packages/core/llms.txt new file mode 100644 index 0000000..7bb4e27 --- /dev/null +++ b/packages/core/llms.txt @@ -0,0 +1,213 @@ +# @moxijs/core + +> Game framework on PIXI.js with ECS architecture and physics. + +## Install + +```bash +npm install @moxijs/core pixi.js +``` + +## Quick Start + +```ts +import { setupMoxi, asEntity, Logic } from '@moxijs/core'; + +const { scene, engine } = await setupMoxi({ + hostElement: document.getElementById('game'), +}); + +class RotateLogic extends Logic { + update(entity, dt) { entity.rotation += 0.01 * dt; } +} + +const sprite = asEntity(new PIXI.Sprite(texture)); +sprite.moxiEntity.addLogic(new RotateLogic()); +scene.addChild(sprite); +engine.start(); +``` + +## Core API + +### setupMoxi(options) → Promise +Main entry point. Returns `{ scene, engine, camera, physicsWorld?, loadAssets }`. + +```ts +await setupMoxi({ + hostElement: HTMLElement, // Required + renderOptions?: PIXIOptions, // PIXI renderer options + physics?: boolean | PhysicsOpts, // Enable Planck.js physics + pixelPerfect?: boolean, // Pixel-art mode +}); +``` + +### asEntity(displayObject) → AsEntity +Wraps PIXI.Container with logic system. + +```ts +const entity = asEntity(new PIXI.Sprite(texture)); +entity.moxiEntity.addLogic(logic); +entity.moxiEntity.getLogic('LogicName'); +entity.moxiEntity.removeLogic(logic); +``` + +### Logic +Base class for entity behaviors. + +```ts +class MyLogic extends Logic { + name = 'MyLogic'; + active = true; + + init(entity, renderer) { /* once on scene.init() */ } + update(entity, deltaTime) { /* every frame */ } +} +``` + +### Scene +Root container. Extends PIXI.Container. + +```ts +scene.addChild(entity); +scene.init(); // Initializes all entity logic +``` + +### Engine +Game loop manager. + +```ts +engine.start(); +engine.stop(); +engine.loadStage(newScene); +``` + +## Physics (Planck.js) + +```ts +const { physicsWorld } = await setupMoxi({ physics: true }); + +// Add physics body to entity +entity.moxiEntity.addLogic(new PhysicsBodyLogic(physicsWorld, { + type: 'dynamic', // 'static' | 'dynamic' | 'kinematic' + shape: { type: 'box', width: 32, height: 32 }, + // or: { type: 'circle', radius: 16 } + // or: { type: 'polygon', vertices: [{x,y}...] } + collisionTags: ['player'], + collidesWith: ['enemy', 'ground'], +})); + +// Collision events +physicsWorld.onCollision('player', 'enemy', (event) => { + console.log(event.bodyA, event.bodyB); +}); + +// Debug visualization +physicsWorld.enableDebugRenderer(scene); +``` + +### Physics Helpers + +```ts +import { asPhysicsEntity, hasPhysics, getPhysicsBody } from '@moxijs/core'; + +const entity = asPhysicsEntity(sprite, physicsWorld, bodyOptions); +if (hasPhysics(entity)) { + const body = getPhysicsBody(entity); +} +``` + +## State Machine + +```ts +import { StateMachine, StateLogic } from '@moxijs/core'; + +class IdleState extends StateLogic { + name = 'idle'; + onEnter() { } + onExit() { } + update(entity, dt) { + if (jumping) this.machine.transition('jump'); + } +} + +const fsm = new StateMachine(); +fsm.addState(new IdleState()); +fsm.start('idle'); +``` + +## Camera + +```ts +camera.follow(entity); +camera.setZoom(2); +camera.pan(100, 50); +camera.shake(intensity, duration); +``` + +## PIXI Helpers + +```ts +import { asSprite, asText, asGraphics, asContainer } from '@moxijs/core'; + +const sprite = asSprite(texture, { x: 100, y: 50, anchor: 0.5, scale: 2 }); +const text = asText('Hello', { style: { fill: 0xffffff } }); +``` + +## Texture Utilities + +```ts +import { asTextureFrames, TextureFrameSequences } from '@moxijs/core'; + +// Split spritesheet +const frames = asTextureFrames(texture, { frameWidth: 32, frameHeight: 32 }); + +// Named sequences +const seq = new TextureFrameSequences(texture, { + idle: { start: 0, end: 3 }, + walk: { start: 4, end: 11 }, +}); +const walkFrames = seq.get('walk'); +``` + +## Input + +```ts +import { ClientEvents } from '@moxijs/core'; + +const input = ClientEvents.getInstance(); +if (input.isKeyDown('ArrowRight')) player.x += 5; +const mousePos = input.movePosition; +``` + +## Events + +```ts +import { EventEmitter } from '@moxijs/core'; + +interface Events { + 'score': (n: number) => void; +} +const events = new EventEmitter(); +events.on('score', (n) => console.log(n)); +events.emit('score', 100); +``` + +## Exports Summary + +**Core:** `setupMoxi`, `Engine`, `Scene`, `asEntity`, `Logic`, `StateMachine`, `StateLogic` + +**Physics:** `PhysicsWorld`, `PhysicsBodyLogic`, `asPhysicsEntity`, `hasPhysics`, `getPhysicsBody` + +**Camera:** `Camera`, `CameraLogic` + +**Helpers:** `asSprite`, `asText`, `asGraphics`, `asContainer`, `asTextureFrames`, `TextureFrameSequences` + +**Input/Events:** `ClientEvents`, `EventEmitter`, `ActionManager` + +**Utils:** `utils.lerp`, `utils.clamp`, `utils.getRandomInt`, `utils.rad2deg`, `utils.deg2rad` + +## See Also + +- **@moxijs/ui** - UI components (UIButton, UILabel, FlexContainer, etc.) +- **Docs:** https://ineffably.github.io/moxijs/docs/core/ +- **Examples:** https://ineffably.github.io/moxijs/ diff --git a/packages/core/package.json b/packages/core/package.json index eac0a75..c93b824 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,8 @@ "lib", "scripts", "README.md", - "LICENSE" + "LICENSE", + "llms.txt" ], "scripts": { "generate-msdf-font": "node scripts/generate-msdf-font.mjs", diff --git a/packages/ui/llms.txt b/packages/ui/llms.txt new file mode 100644 index 0000000..581dfa6 --- /dev/null +++ b/packages/ui/llms.txt @@ -0,0 +1,273 @@ +# @moxijs/ui + +> UI component library for PIXI.js games with theming and flexbox layout. + +## Install + +```bash +npm install @moxijs/ui pixi.js +``` + +## Quick Start + +```ts +import { FlexContainer, FlexDirection, UIButton, UILabel, EdgeInsets } from '@moxijs/ui'; + +const container = new FlexContainer({ + direction: FlexDirection.Column, + gap: 16, + padding: EdgeInsets.all(20), +}); + +container.addChild(new UILabel({ text: 'Welcome!', fontSize: 24 })); +container.addChild(new UIButton({ + label: 'Start', + width: 150, + height: 40, + onClick: () => console.log('Clicked!'), +})); + +container.layout(800, 600); +stage.addChild(container.container); +``` + +## Layout + +### FlexContainer +Flexbox-style layout container. + +```ts +const row = new FlexContainer({ + direction: FlexDirection.Row, // Row | Column + justify: FlexJustify.SpaceBetween, // Start | End | Center | SpaceBetween | SpaceAround + align: FlexAlign.Center, // Start | End | Center | Stretch + gap: 8, + padding: EdgeInsets.symmetric(16, 24), + backgroundColor: 0x333333, +}); +row.addChild(child1); +row.addChild(child2); +row.layout(width, height); +``` + +### EdgeInsets +Padding/margin helper. + +```ts +EdgeInsets.all(16); // All sides +EdgeInsets.symmetric(8, 16); // Vertical, Horizontal +EdgeInsets.only({ top: 10, left: 20 }); // Specific sides +EdgeInsets.zero(); // No padding +``` + +## Components + +### UIButton + +```ts +const button = new UIButton({ + label: 'Click Me', + width: 120, + height: 40, + backgroundColor: 0x4a90e2, + textColor: 0xffffff, + onClick: () => {}, + onHover: () => {}, +}); +``` + +### UILabel + +```ts +const label = new UILabel({ + text: 'Hello', + fontSize: 16, + color: 0xffffff, + fontFamily: 'Arial', + align: 'center', // 'left' | 'center' | 'right' +}); +label.setText('Updated'); +label.setColor(0xff0000); +``` + +### UITextInput + +```ts +const input = new UITextInput({ + width: 200, + height: 32, + placeholder: 'Enter text...', + value: '', + onChange: (value) => {}, +}); +input.getValue(); +input.setValue('new value'); +``` + +### UITextArea + +```ts +const textarea = new UITextArea({ + width: 300, + height: 100, + placeholder: 'Description...', + onChange: (value) => {}, +}); +``` + +### UICheckbox + +```ts +const checkbox = new UICheckbox({ + checked: false, + size: 20, + onChange: (checked) => {}, +}); +``` + +### UIRadioGroup + +```ts +const radio = new UIRadioGroup({ + options: [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + ], + value: 'a', + direction: 'vertical', + onChange: (value) => {}, +}); +``` + +### UISelect + +```ts +const select = new UISelect({ + width: 200, + options: [ + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2' }, + ], + value: 'opt1', + onChange: (value) => {}, +}); +``` + +### UIPanel + +```ts +const panel = new UIPanel({ + width: 300, + height: 200, + backgroundColor: 0x222222, + borderRadius: 8, + borderColor: 0x444444, + borderWidth: 1, +}); +``` + +### UITabs + +```ts +const tabs = new UITabs({ + width: 400, + height: 300, + items: [ + { key: 'tab1', label: 'Tab 1', content: panel1 }, + { key: 'tab2', label: 'Tab 2', content: panel2 }, + ], + activeKey: 'tab1', + onChange: (key) => {}, +}); +``` + +### UIScrollContainer + +```ts +const scroll = new UIScrollContainer({ + width: 300, + height: 400, + scrollbarWidth: 12, +}); +scroll.setContent(content); +scroll.scrollTo(y); +scroll.scrollToBottom(); +``` + +### CardPanel + +```ts +const card = new CardPanel({ + title: 'Settings', + width: 300, + height: 200, +}); +card.body.addChild(content); +``` + +## Theming + +### ThemeManager + +```ts +import { ThemeManager, createDefaultDarkTheme, createDefaultLightTheme } from '@moxijs/ui'; + +const manager = new ThemeManager('app-theme'); +manager.registerTheme({ name: 'Dark', variant: 'dark', theme: createDefaultDarkTheme() }); +manager.registerTheme({ name: 'Light', variant: 'light', theme: createDefaultLightTheme() }); +manager.setTheme('Dark'); +manager.addListener((theme, info) => { /* rebuild UI */ }); +``` + +### ThemeResolver + +```ts +import { ThemeResolver } from '@moxijs/ui'; + +const resolver = new ThemeResolver(manager.getTheme()); + +// Pass to components for automatic theming +const button = new UIButton({ themeResolver: resolver, label: 'OK' }); +const input = new UITextInput({ themeResolver: resolver }); +``` + +## Focus Management + +```ts +import { UIFocusManager } from '@moxijs/ui'; + +const focus = new UIFocusManager(); +focus.register(input1); +focus.register(input2); +focus.focusFirst(); +focus.focusNext(); // Tab navigation +``` + +## UILayer & Scaling + +```ts +import { UILayer, UIScaleMode } from '@moxijs/ui'; + +const ui = new UILayer({ + scaleMode: UIScaleMode.LockRatio, // None | ScaleUI | LockRatio + targetWidth: 1280, + targetHeight: 720, +}); +stage.addChild(ui); +``` + +## Exports Summary + +**Layout:** `FlexContainer`, `FlexDirection`, `FlexJustify`, `FlexAlign`, `EdgeInsets`, `BoxModel` + +**Components:** `UIButton`, `UILabel`, `UIPanel`, `UITextInput`, `UITextArea`, `UICheckbox`, `UIRadioGroup`, `UISelect`, `UITabs`, `UIScrollContainer`, `CardPanel` + +**Theming:** `ThemeManager`, `ThemeResolver`, `createDefaultDarkTheme`, `createDefaultLightTheme` + +**Base:** `UIComponent`, `UIFocusManager`, `UILayer`, `UIScaleMode` + +## See Also + +- **@moxijs/core** - Game engine, physics, entity system +- **Docs:** https://ineffably.github.io/moxijs/docs/ui/ +- **Examples:** https://ineffably.github.io/moxijs/ diff --git a/packages/ui/package.json b/packages/ui/package.json index 11d17d6..3653ec0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,8 @@ "files": [ "lib", "README.md", - "LICENSE" + "LICENSE", + "llms.txt" ], "scripts": { "build": "rimraf ./lib && webpack --mode production", From 28af39c8152bbf081b60e1bb10b39ddaddbe735e Mon Sep 17 00:00:00 2001 From: Chad Neff Date: Sun, 7 Dec 2025 01:42:05 -0800 Subject: [PATCH 2/5] feat(ui): add font family inheritance and custom font support - Add getInheritedFontFamily() to UIComponent for font inheritance through component hierarchy - Support fontFamily and msdfFontFamily props in UIButton, UITextInput, and UITextarea - Add bitmapFontFamily support to UIScrollContainer for consistent typography - Update FlexContainer to properly inherit fonts to child components - Document font inheritance behavior and depth limitations - Improve UITextInput to use inherited fonts instead of hardcoded defaults This enables consistent font customization across all UI components while supporting inheritance through nested containers, addressing issues with fixed font families and lack of typographic flexibility in UI components. --- packages/ui/CHANGELOG.md | 29 ++++ packages/ui/README.md | 34 ++++- packages/ui/src/base/ui-component.ts | 30 ++++ packages/ui/src/components/ui-button.ts | 11 +- .../ui/src/components/ui-scroll-container.ts | 138 ++++++++++++++++-- packages/ui/src/components/ui-text-input.ts | 83 ++++++++++- packages/ui/src/components/ui-textarea.ts | 92 ++++++++++++ packages/ui/src/layout/flex-container.ts | 8 + packages/ui/tests/base/ui-component.test.ts | 71 +++++++-- .../ui/tests/layout/flex-container.test.ts | 28 ++++ 10 files changed, 490 insertions(+), 34 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index ba86796..12fe851 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +#### UIComponent +- `x` and `y` getters/setters for convenient positioning (no need to access `.container` directly) + +#### UITextInput +- `fontFamily` prop for custom font support (inherits from parent if not specified) +- `setSize(width, height?)` method for dynamic resizing +- `setWidth(width)`, `getWidth()`, `getHeight()` convenience methods + +#### UITextArea +- `setSize(width, height?)` method for dynamic resizing +- `setWidth(width)`, `getWidth()`, `getHeight()` convenience methods + +#### UIButton +- `fontFamily` prop for canvas text (in addition to existing `msdfFontFamily` and `bitmapFontFamily`) + +#### UIScrollContainer +- Smooth scrolling enabled by default with new props: + - `smoothScroll` - Enable/disable eased scrolling (default: `true`) + - `scrollEasing` - Easing factor, 0.1=slow, 0.3=snappy (default: `0.15`) + - `scrollSpeed` - Wheel event multiplier (default: `1`) +- `scrollPaddingBottom` - Extra scrollable height at bottom (useful for chat UIs) +- `setScrollPaddingBottom(padding)` and `getScrollPaddingBottom()` methods +- `scrollTo(y, animate?)` now accepts optional `animate` parameter + +#### FlexContainer +- `removeAllChildren()` method for clearing all children at once + ## [0.3.2] - 2025-12-06 ### Added diff --git a/packages/ui/README.md b/packages/ui/README.md index 2bc6933..d69b48c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -58,6 +58,9 @@ const row = new FlexContainer({ row.addChild(button1); row.addChild(button2); row.layout(400, 100); + +// Clear all children +row.removeAllChildren(); ``` ### Form Controls @@ -71,6 +74,9 @@ const button = new UIButton({ height: 40, backgroundColor: 0x4a90e2, textColor: 0xffffff, + fontFamily: 'PixelOperator', // Optional: custom font for canvas text + // Or use MSDF for crisp scaling: + // msdfFontFamily: 'MyMsdfFont', onClick: () => console.log('Clicked!'), onHover: () => console.log('Hovered!') }); @@ -84,8 +90,13 @@ const input = new UITextInput({ height: 32, placeholder: 'Enter text...', value: '', + fontFamily: 'PixelOperator', // Optional: custom font (inherits from parent) onChange: (value) => console.log('Input:', value) }); + +// Dynamic resizing (useful in resizable dialogs) +input.setSize(300, 40); +input.setWidth(250); ``` #### UITextArea @@ -97,6 +108,9 @@ const textarea = new UITextArea({ placeholder: 'Enter description...', onChange: (value) => console.log('Text:', value) }); + +// Dynamic resizing +textarea.setSize(400, 150); ``` #### UICheckbox @@ -163,6 +177,10 @@ const panel = new UIPanel({ borderColor: 0x444444, borderWidth: 1 }); + +// All UIComponents now have x/y getters/setters +panel.x = 100; +panel.y = 50; ``` ### Navigation @@ -188,11 +206,21 @@ const tabs = new UITabs({ const scroll = new UIScrollContainer({ width: 300, height: 400, - scrollbarWidth: 12 + scrollbarWidth: 12, + // Smooth scrolling (enabled by default) + smoothScroll: true, + scrollEasing: 0.15, // 0.1 = slow, 0.3 = snappy + scrollSpeed: 1, // Wheel sensitivity multiplier + scrollPaddingBottom: 50 // Extra scroll space at bottom (for chat UIs) }); -scroll.setContent(tallContent); -scroll.scrollTo(100); + +scroll.addChild(tallContent); +scroll.scrollTo(100); // Smooth scroll to position +scroll.scrollTo(100, false); // Instant scroll (no animation) scroll.scrollToBottom(); + +// Dynamic scroll padding +scroll.setScrollPaddingBottom(100); ``` ## Theming diff --git a/packages/ui/src/base/ui-component.ts b/packages/ui/src/base/ui-component.ts index 4e4e778..881bdb8 100644 --- a/packages/ui/src/base/ui-component.ts +++ b/packages/ui/src/base/ui-component.ts @@ -353,6 +353,36 @@ export abstract class UIComponent implements IFlexLayoutParticipant { }; } + /** + * Gets the x position of this component + */ + public get x(): number { + return this.container.x; + } + + /** + * Sets the x position of this component + */ + public set x(value: number) { + this.computedLayout.x = value; + this.container.x = value; + } + + /** + * Gets the y position of this component + */ + public get y(): number { + return this.container.y; + } + + /** + * Sets the y position of this component + */ + public set y(value: number) { + this.computedLayout.y = value; + this.container.y = value; + } + /** * Sets the position of this component */ diff --git a/packages/ui/src/components/ui-button.ts b/packages/ui/src/components/ui-button.ts index e27258a..af7617b 100644 --- a/packages/ui/src/components/ui-button.ts +++ b/packages/ui/src/components/ui-button.ts @@ -43,6 +43,8 @@ export interface UIButtonProps { textColor?: number; /** Font size */ fontSize?: number; + /** Font family for canvas text (inherits from parent if not specified) */ + fontFamily?: string; /** Border radius (only used with backgroundColor) */ borderRadius?: number; /** Padding inside button */ @@ -97,6 +99,8 @@ export class UIButton extends UIComponent { private bitmapFontFamily?: string; /** Local msdfFontFamily prop (can be overridden by parent inheritance) */ private localMsdfFontFamily?: string; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private localFontFamily?: string; private onClick?: () => void; private onHover?: () => void; @@ -144,6 +148,7 @@ export class UIButton extends UIComponent { this.useBitmapText = props.useBitmapText ?? false; this.bitmapFontFamily = props.bitmapFontFamily; this.localMsdfFontFamily = props.msdfFontFamily; + this.localFontFamily = props.fontFamily; this.onClick = props.onClick; this.onHover = props.onHover; @@ -244,12 +249,14 @@ export class UIButton extends UIComponent { padding: EdgeInsets.zero() }); } else { - // Regular canvas text + // Regular canvas text - resolve font family from local or parent + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily); this.label = new UILabel({ text: this.props.label, fontSize: this.props.fontSize, color: this.props.textColor, - align: 'center' + align: 'center', + fontFamily: effectiveFontFamily }, { padding: EdgeInsets.zero() }); diff --git a/packages/ui/src/components/ui-scroll-container.ts b/packages/ui/src/components/ui-scroll-container.ts index 552bab1..620c753 100644 --- a/packages/ui/src/components/ui-scroll-container.ts +++ b/packages/ui/src/components/ui-scroll-container.ts @@ -30,6 +30,19 @@ export interface UIScrollContainerProps { scrollbarAutoHide?: boolean; /** Padding inside the scroll container */ padding?: EdgeInsets; + /** Enable smooth/eased scrolling (default: true) */ + smoothScroll?: boolean; + /** Easing factor for smooth scroll (0.1 = slow, 0.3 = snappy, default: 0.15) */ + scrollEasing?: number; + /** Scroll speed multiplier for wheel events (default: 1) */ + scrollSpeed?: number; + /** + * Extra height added to scrollable content area. + * Allows content to scroll beyond its natural bounds. + * Useful for chat interfaces where the last item should scroll to the top. + * (default: 0) + */ + scrollPaddingBottom?: number; } /** @@ -64,6 +77,7 @@ export class UIScrollContainer extends UIComponent { public children: UIComponent[] = []; private scrollY: number = 0; + private targetScrollY: number = 0; private maxScrollY: number = 0; private contentHeight: number = 0; private isDragging: boolean = false; @@ -71,6 +85,10 @@ export class UIScrollContainer extends UIComponent { private dragStartScrollY: number = 0; private isHoveringThumb: boolean = false; + // Smooth scrolling animation + private animationFrameId: number | null = null; + private isAnimating: boolean = false; + // Bound handlers for proper cleanup private boundGlobalPointerMove: ((e: PointerEvent) => void) | null = null; private boundGlobalPointerUp: (() => void) | null = null; @@ -89,7 +107,11 @@ export class UIScrollContainer extends UIComponent { scrollbarThumbColor: props.scrollbarThumbColor ?? 0x888888, scrollbarThumbHoverColor: props.scrollbarThumbHoverColor ?? 0x555555, scrollbarAutoHide: props.scrollbarAutoHide ?? false, - padding: props.padding ?? EdgeInsets.all(0) + padding: props.padding ?? EdgeInsets.all(0), + smoothScroll: props.smoothScroll ?? true, + scrollEasing: props.scrollEasing ?? 0.15, + scrollSpeed: props.scrollSpeed ?? 1, + scrollPaddingBottom: props.scrollPaddingBottom ?? 0 }; // Set box model dimensions @@ -211,12 +233,56 @@ export class UIScrollContainer extends UIComponent { private handleWheel(e: PIXI.FederatedWheelEvent): void { e.preventDefault(); - const delta = e.deltaY; - this.scrollY += delta; - this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + const delta = e.deltaY * this.props.scrollSpeed; + + if (this.props.smoothScroll) { + // Smooth scrolling: set target and animate + this.targetScrollY += delta; + this.targetScrollY = Math.max(0, Math.min(this.maxScrollY, this.targetScrollY)); + this.startSmoothScroll(); + } else { + // Instant scrolling (legacy behavior) + this.scrollY += delta; + this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + this.targetScrollY = this.scrollY; + this.updateContentPosition(); + this.updateScrollbar(); + } + } + + /** + * Starts the smooth scroll animation loop + */ + private startSmoothScroll(): void { + if (this.isAnimating) return; + + this.isAnimating = true; + this.animateSmoothScroll(); + } + /** + * Animation frame for smooth scrolling + */ + private animateSmoothScroll(): void { + const diff = this.targetScrollY - this.scrollY; + + // Stop animation when close enough + if (Math.abs(diff) < 0.5) { + this.scrollY = this.targetScrollY; + this.isAnimating = false; + this.animationFrameId = null; + this.updateContentPosition(); + this.updateScrollbar(); + return; + } + + // Ease toward target + this.scrollY += diff * this.props.scrollEasing; this.updateContentPosition(); this.updateScrollbar(); + + // Continue animation + this.animationFrameId = requestAnimationFrame(() => this.animateSmoothScroll()); } /** @@ -240,11 +306,17 @@ export class UIScrollContainer extends UIComponent { // Calculate target scroll position const clickRatio = (localY - trackTop) / (trackBottom - trackTop); - this.scrollY = clickRatio * this.maxScrollY; - this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + const targetY = Math.max(0, Math.min(this.maxScrollY, clickRatio * this.maxScrollY)); - this.updateContentPosition(); - this.updateScrollbar(); + if (this.props.smoothScroll) { + this.targetScrollY = targetY; + this.startSmoothScroll(); + } else { + this.scrollY = targetY; + this.targetScrollY = targetY; + this.updateContentPosition(); + this.updateScrollbar(); + } } /** @@ -263,6 +335,8 @@ export class UIScrollContainer extends UIComponent { const scrollDelta = (deltaY / scrollableTrackHeight) * this.maxScrollY; this.scrollY = this.dragStartScrollY + scrollDelta; this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + // Sync target to prevent animation fighting with drag + this.targetScrollY = this.scrollY; this.updateContentPosition(); this.updateScrollbar(); @@ -380,10 +454,12 @@ export class UIScrollContainer extends UIComponent { this.contentHeight = maxHeight; const viewportHeight = this.props.height - this.props.padding.top - this.props.padding.bottom; - this.maxScrollY = Math.max(0, this.contentHeight - viewportHeight); + // Add scrollPaddingBottom to allow content to scroll beyond its natural bounds + this.maxScrollY = Math.max(0, this.contentHeight + this.props.scrollPaddingBottom - viewportHeight); - // Clamp current scroll position + // Clamp current scroll position and target this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + this.targetScrollY = Math.max(0, Math.min(this.maxScrollY, this.targetScrollY)); this.updateContentPosition(); this.updateScrollbar(); @@ -427,11 +503,22 @@ export class UIScrollContainer extends UIComponent { /** * Scrolls to a specific Y position + * @param y - Target scroll position + * @param animate - Whether to animate the scroll (default: uses smoothScroll prop) */ - scrollTo(y: number): void { - this.scrollY = Math.max(0, Math.min(this.maxScrollY, y)); - this.updateContentPosition(); - this.updateScrollbar(); + scrollTo(y: number, animate?: boolean): void { + const shouldAnimate = animate ?? this.props.smoothScroll; + const clampedY = Math.max(0, Math.min(this.maxScrollY, y)); + + if (shouldAnimate) { + this.targetScrollY = clampedY; + this.startSmoothScroll(); + } else { + this.scrollY = clampedY; + this.targetScrollY = clampedY; + this.updateContentPosition(); + this.updateScrollbar(); + } } /** @@ -462,6 +549,22 @@ export class UIScrollContainer extends UIComponent { return this.maxScrollY; } + /** + * Sets the scroll padding bottom (extra scrollable area at bottom) + * @param padding - Extra height in pixels + */ + setScrollPaddingBottom(padding: number): void { + this.props.scrollPaddingBottom = padding; + this.updateContentBounds(); + } + + /** + * Gets the current scroll padding bottom + */ + getScrollPaddingBottom(): number { + return this.props.scrollPaddingBottom; + } + /** * Scrolls to make a child component visible */ @@ -518,6 +621,13 @@ export class UIScrollContainer extends UIComponent { * Cleanup when destroying */ destroy(): void { + // Cancel any pending animation frame + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.isAnimating = false; + if (typeof window !== 'undefined') { if (this.boundGlobalPointerMove) { window.removeEventListener('pointermove', this.boundGlobalPointerMove); diff --git a/packages/ui/src/components/ui-text-input.ts b/packages/ui/src/components/ui-text-input.ts index 947f2ad..d97908b 100644 --- a/packages/ui/src/components/ui-text-input.ts +++ b/packages/ui/src/components/ui-text-input.ts @@ -45,6 +45,8 @@ export interface UITextInputProps { borderRadius?: number; /** Font size */ fontSize?: number; + /** Font family (inherits from parent if not specified) */ + fontFamily?: string; /** Input type for validation */ type?: 'text' | 'number'; /** Optional ThemeResolver for automatic color resolution */ @@ -68,7 +70,9 @@ export interface UITextInputProps { */ export class UITextInput extends UIComponent { // Props - private props: Required>; + private props: Required>; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private localFontFamily?: string; // Services (composition) private stateManager: FormStateManager; @@ -120,6 +124,9 @@ export class UITextInput extends UIComponent { placeholderColor: props.placeholderColor }; + // Store fontFamily for inheritance + this.localFontFamily = props.fontFamily; + // Initialize theme resolver this.themeResolver = props.themeResolver; @@ -174,10 +181,13 @@ export class UITextInput extends UIComponent { ? this.resolvePlaceholderColor(this.colorOverrides.placeholderColor) : this.resolveTextColor(this.colorOverrides.textColor); + // Resolve font family - check local prop first, then inherit from parent + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; + this.textDisplay = asTextDPR({ text: displayText, style: { - fontFamily: UI_DEFAULTS.FONT_FAMILY, + fontFamily: effectiveFontFamily, fontSize: this.props.fontSize, fill: textColor, align: 'left' @@ -330,10 +340,12 @@ export class UITextInput extends UIComponent { this.inputHandler.setCursorPosition(value.length); } const dprScale = UI_DEFAULTS.DPR_SCALE; + // Use same font family as display text + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; const tempText = new PIXI.Text({ text: textUpToCursor, style: { - fontFamily: UI_DEFAULTS.FONT_FAMILY, // Match the display font + fontFamily: effectiveFontFamily, // Match the display font fontSize: this.props.fontSize * dprScale, // Account for DPR scaling fill: this.textDisplay.style.fill, align: 'left' @@ -438,6 +450,71 @@ export class UITextInput extends UIComponent { this.updateCursor(); } + /** + * Sets the size of the input and redraws + * @param width - New width in pixels + * @param height - New height in pixels (optional, keeps current if not provided) + */ + setSize(width: number, height?: number): void { + this.props.width = width; + if (height !== undefined) { + this.props.height = height; + } + this.boxModel.width = this.props.width; + this.boxModel.height = this.props.height; + + // Recreate visuals with new size + this.recreateVisuals(); + this.markLayoutDirty(); + } + + /** + * Sets the width of the input + */ + setWidth(width: number): void { + this.setSize(width, this.props.height); + } + + /** + * Gets the current width + */ + getWidth(): number { + return this.props.width; + } + + /** + * Gets the current height + */ + getHeight(): number { + return this.props.height; + } + + /** + * Recreates visual elements (used after size change) + */ + private recreateVisuals(): void { + // Update background size + this.background.destroy(); + + const bgColor = this.resolveColor('background', this.colorOverrides.backgroundColor); + this.background = new UIPanel({ + backgroundColor: bgColor, + width: this.props.width, + height: this.props.height, + borderRadius: this.props.borderRadius + }); + + // Insert background at position 0 (behind other elements) + this.container.addChildAt(this.background.container, 0); + + // Update text position + const textY = Math.round((this.props.height - this.props.fontSize) / 2); + this.textDisplay.position.set(12, textY); + + // Update cursor + this.updateCursor(); + } + /** * Cleanup when destroying */ diff --git a/packages/ui/src/components/ui-textarea.ts b/packages/ui/src/components/ui-textarea.ts index b01f487..54ebe30 100644 --- a/packages/ui/src/components/ui-textarea.ts +++ b/packages/ui/src/components/ui-textarea.ts @@ -474,6 +474,98 @@ export class UITextArea extends UIComponent { this.updateText(); } + /** + * Sets the size of the textarea and redraws + * @param width - New width in pixels + * @param height - New height in pixels (optional, keeps current if not provided) + */ + setSize(width: number, height?: number): void { + this.props.width = width; + if (height !== undefined) { + this.props.height = height; + } + this.boxModel.width = this.props.width; + this.boxModel.height = this.props.height; + + // Recreate visuals with new size + this.recreateVisuals(); + this.markLayoutDirty(); + } + + /** + * Sets the width of the textarea + */ + setWidth(width: number): void { + this.setSize(width, this.props.height); + } + + /** + * Gets the current width + */ + getWidth(): number { + return this.props.width; + } + + /** + * Gets the current height + */ + getHeight(): number { + return this.props.height; + } + + /** + * Recreates visual elements (used after size change) + */ + private recreateVisuals(): void { + // Remove old visuals + this.background.destroy(); + this.container.removeChild(this.textDisplay); + this.textDisplay.destroy(); + + // Recreate background + const bgColor = this.focused + ? this.resolveColor('focus', this.colorOverrides.backgroundColor) + : this.resolveColor('background', this.colorOverrides.backgroundColor); + + this.background = new UIPanel({ + backgroundColor: bgColor, + width: this.props.width, + height: this.props.height, + borderRadius: this.props.borderRadius + }); + this.container.addChildAt(this.background.container, 0); + + // Recreate text display with new word wrap width + const displayText = this.getDisplayText(); + const dprScale = UI_DEFAULTS.DPR_SCALE; + const value = this.stateManager.getValue(); + const textColor = value + ? this.resolveTextColor(this.colorOverrides.textColor) + : this.resolvePlaceholderColor(this.colorOverrides.placeholderColor); + + this.textDisplay = asTextDPR({ + text: displayText, + style: { + fontFamily: UI_DEFAULTS.FONT_FAMILY, + fontSize: this.props.fontSize, + fill: textColor, + align: 'left', + wordWrap: true, + wordWrapWidth: (this.props.width - 24) * dprScale, + lineHeight: this.props.fontSize * this.props.lineHeight * dprScale + }, + dprScale, + pixelPerfect: true + }); + this.textDisplay.position.set(12, 12); + this.textDisplay.roundPixels = true; + // Insert before cursor (cursor should be last) + this.container.addChildAt(this.textDisplay, this.container.children.length - 1); + + // Update cursor position + this.updateCursor(); + } + /** * Cleanup when destroying */ diff --git a/packages/ui/src/layout/flex-container.ts b/packages/ui/src/layout/flex-container.ts index b5878ab..3c732d5 100644 --- a/packages/ui/src/layout/flex-container.ts +++ b/packages/ui/src/layout/flex-container.ts @@ -184,6 +184,14 @@ export class FlexContainer extends UIComponent { } } + /** Remove all children from this container. */ + removeAllChildren(): void { + // Remove in reverse order to avoid index shifting issues + while (this.children.length > 0) { + this.removeChild(this.children[this.children.length - 1]); + } + } + /** * Automatically unregisters a focusable component from the global focus manager. * Recursively checks children if the component is a container. diff --git a/packages/ui/tests/base/ui-component.test.ts b/packages/ui/tests/base/ui-component.test.ts index 439846b..ba38b7c 100644 --- a/packages/ui/tests/base/ui-component.test.ts +++ b/packages/ui/tests/base/ui-component.test.ts @@ -4,17 +4,32 @@ import { EdgeInsets } from '../../src/base/edge-insets'; // Mock PIXI.js jest.mock('pixi.js', () => ({ - Container: jest.fn().mockImplementation(() => ({ - addChild: jest.fn(), - addChildAt: jest.fn(), - removeChild: jest.fn(), - destroy: jest.fn(), - position: { set: jest.fn() }, - visible: true, - parent: null, - effects: [], - getGlobalPosition: jest.fn().mockReturnValue({ x: 0, y: 0 }), - })), + Container: jest.fn().mockImplementation(() => { + const container: any = { + addChild: jest.fn(), + addChildAt: jest.fn(), + removeChild: jest.fn(), + destroy: jest.fn(), + _x: 0, + _y: 0, + get x() { return this._x; }, + set x(v: number) { this._x = v; }, + get y() { return this._y; }, + set y(v: number) { this._y = v; }, + visible: true, + parent: null, + effects: [], + getGlobalPosition: jest.fn().mockReturnValue({ x: 0, y: 0 }), + }; + // position.set should update both x and y like real PIXI + container.position = { + set: jest.fn((x: number, y: number) => { + container._x = x; + container._y = y; + }) + }; + return container; + }), Graphics: jest.fn().mockImplementation(() => ({ clear: jest.fn().mockReturnThis(), roundRect: jest.fn().mockReturnThis(), @@ -239,13 +254,45 @@ describe('UIComponent', () => { }); describe('position', () => { - it('should set position', () => { + it('should set position via setPosition()', () => { component.setPosition(100, 200); const layout = component.getLayout(); expect(layout.x).toBe(100); expect(layout.y).toBe(200); }); + + it('should get x position', () => { + component.setPosition(150, 250); + expect(component.x).toBe(150); + }); + + it('should get y position', () => { + component.setPosition(150, 250); + expect(component.y).toBe(250); + }); + + it('should set x position via setter', () => { + component.x = 300; + expect(component.x).toBe(300); + expect(component.getLayout().x).toBe(300); + }); + + it('should set y position via setter', () => { + component.y = 400; + expect(component.y).toBe(400); + expect(component.getLayout().y).toBe(400); + }); + + it('should update container position when setting x', () => { + component.x = 100; + expect(component.container.x).toBe(100); + }); + + it('should update container position when setting y', () => { + component.y = 200; + expect(component.container.y).toBe(200); + }); }); describe('state', () => { diff --git a/packages/ui/tests/layout/flex-container.test.ts b/packages/ui/tests/layout/flex-container.test.ts index c906c48..7bc7273 100644 --- a/packages/ui/tests/layout/flex-container.test.ts +++ b/packages/ui/tests/layout/flex-container.test.ts @@ -220,6 +220,34 @@ describe('FlexContainer', () => { // (we can't directly test layoutDirty, but we can verify behavior) expect(container.children.length).toBe(1); }); + + it('should remove all children', () => { + const container = new FlexContainer(); + const child1 = new MockChildComponent(); + const child2 = new MockChildComponent(); + const child3 = new MockChildComponent(); + + container.addChild(child1); + container.addChild(child2); + container.addChild(child3); + expect(container.children.length).toBe(3); + + container.removeAllChildren(); + + expect(container.children.length).toBe(0); + expect(child1.parent).toBeUndefined(); + expect(child2.parent).toBeUndefined(); + expect(child3.parent).toBeUndefined(); + }); + + it('should handle removeAllChildren on empty container', () => { + const container = new FlexContainer(); + + // Should not throw + container.removeAllChildren(); + + expect(container.children.length).toBe(0); + }); }); describe('measure', () => { From e6b487dfbd3211730f6c9224a307a023d8fec7ee Mon Sep 17 00:00:00 2001 From: Chad Neff Date: Sun, 7 Dec 2025 01:58:33 -0800 Subject: [PATCH 3/5] Fix font inheritance in UI components and improve text input customization - Add fontFamily and msdfFontFamily props to UITextInput and UITextArea - Implement getInheritedFontFamily() for deep font inheritance through parent chains - Update UILabel to use inherited fonts from parent containers - Add fontConfig support to UIScrollContainer for cascading font styles - Fix deep inheritance through nested FlexContainers and UIScrollContainers - Add setSize() methods to UITextInput and UITextArea for dynamic resizing - Ensure consistent typography across all UI components --- packages/core/src/index.ts | 14 ++- packages/core/src/library/action-manager.ts | 27 +++- packages/core/src/library/as-pixi.ts | 117 ++++++++++++++++++ packages/core/src/library/client-events.ts | 26 +++- packages/core/src/library/setup.ts | 89 ++++++++++++- packages/ui/src/components/ui-label.ts | 20 ++- .../ui/src/components/ui-scroll-container.ts | 15 ++- 7 files changed, 292 insertions(+), 16 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04b545a..03382f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ import { loadFonts } from './library/font-loader'; import { asEntity, MoxiEntity } from './main/moxi-entity'; import { Camera, CameraLogic } from './main/camera'; import { asTextureFrames } from './library/texture-frames'; -import { asBitmapText, asSprite, asText, asTextDPR, asGraphics, asContainer, asMSDFText, PixiProps, TextDPROptions } from './library/as-pixi'; +import { asBitmapText, asSprite, asText, asTextDPR, asGraphics, asContainer, asMSDFText, asEntityGraphics, asEntitySprite, asEntityContainer, asEntityText, PixiProps, TextDPROptions } from './library/as-pixi'; import { TextureFrameSequences, SequenceInfo } from './library/texture-frame-sequences'; import { createResizeHandler, setupResponsiveCanvas, ResizeHandlerOptions } from './library/resize-handler'; import { StateMachine } from './library/state-machine'; @@ -64,8 +64,8 @@ export type { } from './library/physics'; export type { SVGToTextureOptions } from './library/svg-utils/svg-to-texture'; export type { PixelGridConfig, BorderConfig } from './library/pixel-grid'; -export type { SpriteOptions, TextOptions, TextDPROptions, BitmapTextOptions, PixiProps, MSDFTextOptions } from './library/as-pixi'; -export type { SetupMoxiArgs, PixelPerfectOptions } from './library/setup'; +export type { SpriteOptions, TextOptions, TextDPROptions, BitmapTextOptions, PixiProps, PixiPropsWithEntity, MSDFTextOptions, GraphicsDrawOptions } from './library/as-pixi'; +export type { SetupMoxiArgs, PixelPerfectOptions, ScaleMode } from './library/setup'; export type { AssetLoaderEvents } from './main/asset-loader'; export type Asset = { src: string, alias?: string }; @@ -109,6 +109,10 @@ const exportedObjects = { asGraphics, asContainer, asMSDFText, + asEntityGraphics, + asEntitySprite, + asEntityContainer, + asEntityText, Logic, Camera, CameraLogic, @@ -169,6 +173,10 @@ export { asGraphics, asContainer, asMSDFText, + asEntityGraphics, + asEntitySprite, + asEntityContainer, + asEntityText, Logic, Camera, CameraLogic, diff --git a/packages/core/src/library/action-manager.ts b/packages/core/src/library/action-manager.ts index 8b428de..08124f5 100644 --- a/packages/core/src/library/action-manager.ts +++ b/packages/core/src/library/action-manager.ts @@ -1,24 +1,49 @@ /** - * ActionManager - Centralized event listener management utility + * ActionManager - Centralized event listener management utility. * * Prevents memory leaks by tracking all registered event listeners and providing * batch cleanup functionality. Particularly useful for Logic components that need to * register multiple window/document listeners and clean them up on destroy. * + * ## Why Use ActionManager? + * When adding event listeners in game logic, forgetting to remove them causes memory leaks. + * ActionManager tracks all listeners and provides a single `removeAll()` call for cleanup. + * + * ## Common Event Targets + * - `window` - Global events like resize, blur, focus + * - `document` - Keyboard events, pointer events that shouldn't be canvas-bound + * - `renderer.canvas` - Canvas-specific events + * - Any DOM element + * + * ## Common Event Types + * - Pointer: 'pointerdown', 'pointermove', 'pointerup', 'pointercancel' + * - Mouse: 'mousedown', 'mousemove', 'mouseup', 'wheel', 'contextmenu' + * - Keyboard: 'keydown', 'keyup' + * - Window: 'resize', 'blur', 'focus', 'visibilitychange' + * * @example * ```ts + * // In a Logic component - drag handling with cleanup * class DragLogic extends Logic { * private actions = new ActionManager(); * * init(entity: Container) { + * // Add listeners - they're tracked automatically * this.actions.add(window, 'pointermove', this.onMove.bind(this)); * this.actions.add(window, 'pointerup', this.onUp.bind(this)); + * this.actions.add(window, 'blur', this.onBlur.bind(this)); * } * * destroy() { + * // Clean up all listeners at once - no memory leaks! * this.actions.removeAll(); * } * } + * + * // Removing individual actions + * const wheelAction = actions.add(window, 'wheel', onWheel); + * // ... later ... + * actions.remove(wheelAction); // Remove just this one * ``` */ diff --git a/packages/core/src/library/as-pixi.ts b/packages/core/src/library/as-pixi.ts index eac1da7..6aaaff4 100644 --- a/packages/core/src/library/as-pixi.ts +++ b/packages/core/src/library/as-pixi.ts @@ -1,4 +1,5 @@ import PIXI from 'pixi.js'; +import { asEntity, AsEntity } from '../main/moxi-entity'; /** * Options for creating a BitmapText instance @@ -64,6 +65,17 @@ export interface PixiProps { pivot?: { x?: number; y?: number } | number; } +/** + * Props with optional entity wrapping + */ +export interface PixiPropsWithEntity extends PixiProps { + /** + * If true, wrap the result with asEntity() for logic component support. + * The returned object will have a `moxiEntity` property for adding Logic. + */ + entity?: boolean; +} + /** * Applies common properties to a PIXI display object */ @@ -335,3 +347,108 @@ export function asMSDFText( return applyProps(text, props); } +// ============================================================================ +// Entity-wrapped helpers +// These return objects with moxiEntity property for logic component support +// ============================================================================ + +/** + * Options for asGraphics with draw callback + */ +export interface GraphicsDrawOptions extends PixiProps { + /** Optional draw callback to set up graphics commands */ + draw?: (g: PIXI.Graphics) => void; +} + +/** + * Creates a Graphics instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const player = asEntityGraphics({ + * x: 100, + * y: 200, + * draw: (g) => g.circle(0, 0, 20).fill(0xff0000) + * }); + * player.moxiEntity.addLogic(new PlayerController()); + * scene.addChild(player); + * ``` + */ +export function asEntityGraphics(props?: GraphicsDrawOptions): AsEntity { + const { draw, ...pixiProps } = props || {}; + const graphics = new PIXI.Graphics(); + + if (draw) { + draw(graphics); + } + + const result = applyProps(graphics, pixiProps); + return asEntity(result); +} + +/** + * Creates a Sprite instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const player = asEntitySprite( + * { texture }, + * { x: 100, y: 100, anchor: 0.5 } + * ); + * player.moxiEntity.addLogic(new PlayerController()); + * scene.addChild(player); + * ``` + */ +export function asEntitySprite( + constructorArgs: SpriteOptions, + props?: PixiProps +): AsEntity { + const sprite = new PIXI.Sprite(constructorArgs as any); + const result = applyProps(sprite, props); + return asEntity(result); +} + +/** + * Creates a Container instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const group = asEntityContainer({ x: 100, y: 100 }); + * group.moxiEntity.addLogic(new GroupLogic()); + * scene.addChild(group); + * ``` + */ +export function asEntityContainer(props?: PixiProps): AsEntity { + const container = new PIXI.Container(); + const result = applyProps(container, props); + return asEntity(result); +} + +/** + * Creates a Text instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const label = asEntityText( + * { text: 'Score: 0', style: { fontFamily: 'Arial', fontSize: 24 } }, + * { x: 10, y: 10 } + * ); + * label.moxiEntity.addLogic(new ScoreDisplay()); + * scene.addChild(label); + * ``` + */ +export function asEntityText( + constructorArgs: TextOptions, + props?: PixiProps +): AsEntity { + const { pixelPerfect, ...textArgs } = constructorArgs; + const text = new PIXI.Text(textArgs as any); + + if (pixelPerfect) { + text.roundPixels = true; + } + + const result = applyProps(text, props); + return asEntity(result); +} + diff --git a/packages/core/src/library/client-events.ts b/packages/core/src/library/client-events.ts index 9d5609a..dabfbc2 100644 --- a/packages/core/src/library/client-events.ts +++ b/packages/core/src/library/client-events.ts @@ -5,13 +5,27 @@ import { OnEvent, ClientEventsArgs } from '..'; * Singleton input manager for keyboard, mouse, and wheel events. * Tracks current input state for polling-based input handling. * + * ## Key Format + * Keys are identified by `event.key` (the character or key name), NOT `event.code` (physical key). + * + * Common key values: + * - Letters: 'a', 'b', 'c', ... (lowercase when typed without shift) + * - Arrows: 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight' + * - Modifiers: 'Shift', 'Control', 'Alt', 'Meta' + * - Special: 'Enter', 'Escape', 'Tab', 'Backspace', ' ' (space) + * - Numbers: '0', '1', '2', ... (the character, not 'Digit1') + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + * * @example * ```ts * const input = ClientEvents.getInstance(); * * // In update loop - check key state + * // Use event.key values (NOT event.code like 'KeyE' or 'Space') * if (input.isKeyDown('ArrowRight')) player.moveRight(); - * if (input.isKeyDown('Space')) player.jump(); + * if (input.isKeyDown(' ')) player.jump(); // Space key + * if (input.isKeyDown('e')) player.interact(); // lowercase 'e' * * // Mouse position and movement * const mousePos = input.movePosition; @@ -129,12 +143,18 @@ export class ClientEvents { ClientEvents.instance = this; } - /** True if key is currently pressed. */ + /** + * True if key is currently pressed. + * @param key - The key value (event.key), e.g., 'a', 'ArrowRight', ' ' for space + */ isKeyDown(key: string): boolean { return Boolean(this.keydown[key]); } - /** True if key is not pressed. */ + /** + * True if key is not pressed. + * @param key - The key value (event.key), e.g., 'a', 'ArrowRight', ' ' for space + */ isKeyUp(key: string): boolean { return !this.keydown[key]; } diff --git a/packages/core/src/library/setup.ts b/packages/core/src/library/setup.ts index a6fc502..b9dc23f 100644 --- a/packages/core/src/library/setup.ts +++ b/packages/core/src/library/setup.ts @@ -15,10 +15,31 @@ export interface PixelPerfectOptions { imageRendering?: boolean; } +/** Scale mode for canvas scaling within the host element. */ +export type ScaleMode = 'none' | 'fit' | 'fill' | 'stretch'; + /** Configuration for setupMoxi(). */ export interface SetupMoxiArgs { /** Container element to attach the canvas. */ hostElement: HTMLElement; + /** + * Canvas width in pixels. Convenience option (alternative to renderOptions.width). + * If both are provided, this takes precedence. + */ + width?: number; + /** + * Canvas height in pixels. Convenience option (alternative to renderOptions.height). + * If both are provided, this takes precedence. + */ + height?: number; + /** + * How to scale the canvas within the host element. + * - 'none': No scaling, canvas uses its native size (default) + * - 'fit': Scale to fit within host while maintaining aspect ratio (letterbox/pillarbox) + * - 'fill': Scale to fill host while maintaining aspect ratio (may crop) + * - 'stretch': Stretch to fill host (distorts aspect ratio) + */ + scaleMode?: ScaleMode; /** PIXI render options (width, height, backgroundColor, etc). */ renderOptions?: Partial; /** Background color (hex number like 0x1a1a2e). Convenience option. */ @@ -95,6 +116,9 @@ export const defaultRenderOptions = { */ export async function setupMoxi({ hostElement, + width, + height, + scaleMode = 'none', renderOptions = defaultRenderOptions, backgroundColor, physics, @@ -105,6 +129,15 @@ export async function setupMoxi({ } = {} as SetupMoxiArgs) { // Process pixel-perfect options let finalRenderOptions = { ...renderOptions }; + + // Apply top-level width/height if provided (takes precedence over renderOptions) + if (width !== undefined) { + finalRenderOptions.width = width; + } + if (height !== undefined) { + finalRenderOptions.height = height; + } + let applyImageRendering = false; if (pixelPerfect) { @@ -132,17 +165,22 @@ export async function setupMoxi({ renderer.background.color = backgroundColor; } + const canvas = renderer.canvas as HTMLCanvasElement; + // Apply canvas CSS for pixel-perfect scaling if requested if (applyImageRendering) { - const canvas = renderer.canvas as HTMLCanvasElement; canvas.style.imageRendering = 'pixelated'; canvas.style.imageRendering = '-moz-crisp-edges'; canvas.style.imageRendering = 'crisp-edges'; } + // Apply scale mode CSS + if (scaleMode !== 'none') { + applyScaleMode(hostElement, canvas, scaleMode, finalRenderOptions.width as number, finalRenderOptions.height as number); + } + // Suppress right-click context menu if requested if (suppressContextMenu) { - const canvas = renderer.canvas as HTMLCanvasElement; canvas.addEventListener('contextmenu', (e) => e.preventDefault()); } @@ -202,3 +240,50 @@ export async function setupMoxi({ }; } +/** + * Apply CSS styling for canvas scaling within host element. + * @internal + */ +function applyScaleMode( + hostElement: HTMLElement, + canvas: HTMLCanvasElement, + scaleMode: ScaleMode, + canvasWidth: number, + canvasHeight: number +): void { + // Host element styles for centering + hostElement.style.display = 'flex'; + hostElement.style.alignItems = 'center'; + hostElement.style.justifyContent = 'center'; + hostElement.style.overflow = 'hidden'; + + // Canvas styles based on scale mode + switch (scaleMode) { + case 'fit': + // Letterbox/pillarbox: fit within container while maintaining aspect ratio + canvas.style.maxWidth = '100%'; + canvas.style.maxHeight = '100%'; + canvas.style.width = 'auto'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = `${canvasWidth} / ${canvasHeight}`; + canvas.style.objectFit = 'contain'; + break; + + case 'fill': + // Fill container while maintaining aspect ratio (may crop) + canvas.style.minWidth = '100%'; + canvas.style.minHeight = '100%'; + canvas.style.width = 'auto'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = `${canvasWidth} / ${canvasHeight}`; + canvas.style.objectFit = 'cover'; + break; + + case 'stretch': + // Stretch to fill (distorts) + canvas.style.width = '100%'; + canvas.style.height = '100%'; + break; + } +} + diff --git a/packages/ui/src/components/ui-label.ts b/packages/ui/src/components/ui-label.ts index 49a4355..2779d38 100644 --- a/packages/ui/src/components/ui-label.ts +++ b/packages/ui/src/components/ui-label.ts @@ -26,7 +26,13 @@ export interface UILabelProps { wordWrapWidth?: number; /** Font weight */ fontWeight?: string; - /** Line height multiplier */ + /** + * Line height as a multiplier of fontSize (like CSS unitless line-height). + * - 1.0 = no extra spacing (tight) + * - 1.2 = 20% extra spacing (default) + * - 2.0 = double spacing + * Note: This is NOT absolute pixels - use 1.2, not 24 for an 18px font. + */ lineHeight?: number; /** MSDF font family name. If provided, uses MSDF (Multi-channel Signed Distance Field) text rendering for crisp text at any scale. Must match the loaded MSDF font's family name. */ msdfFontFamily?: string; @@ -65,6 +71,8 @@ export class UILabel extends UIComponent { private readonly dprScale = 2; // DPR scaling factor for crisp text /** Local msdfFontFamily prop (can be overridden by parent inheritance) */ private readonly localMsdfFontFamily?: string; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private readonly localFontFamily?: string; /** Track if text object has been initialized */ private textInitialized = false; @@ -73,13 +81,14 @@ export class UILabel extends UIComponent { constructor(props: UILabelProps, boxModel?: Partial) { super(boxModel); - // Store local prop - actual value will be resolved via inheritance + // Store local props - actual values will be resolved via inheritance this.localMsdfFontFamily = props.msdfFontFamily; + this.localFontFamily = props.fontFamily; this.props = { text: props.text, fontSize: props.fontSize ?? 16, - fontFamily: props.fontFamily ?? UI_DEFAULTS.FONT_FAMILY, // Default to pixel-perfect font + fontFamily: props.fontFamily ?? UI_DEFAULTS.FONT_FAMILY, // Default (may be overridden by inheritance) color: props.color ?? 0xffffff, align: props.align ?? 'left', wordWrap: props.wordWrap ?? false, @@ -131,9 +140,12 @@ export class UILabel extends UIComponent { /** @internal */ private getTextStyle(): { [key: string]: any } { + // Resolve fontFamily through inheritance chain (local -> parent -> default) + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; + // Note: fontSize is multiplied by dprScale in asTextDPR, so we pass display size here const style: { [key: string]: any } = { - fontFamily: this.props.fontFamily, + fontFamily: effectiveFontFamily, fontSize: this.props.fontSize, // Display size - asTextDPR will multiply by dprScale fill: this.props.color, align: this.props.align, diff --git a/packages/ui/src/components/ui-scroll-container.ts b/packages/ui/src/components/ui-scroll-container.ts index 620c753..aaaf9c4 100644 --- a/packages/ui/src/components/ui-scroll-container.ts +++ b/packages/ui/src/components/ui-scroll-container.ts @@ -1,5 +1,5 @@ import * as PIXI from 'pixi.js'; -import { UIComponent } from '../base/ui-component'; +import { UIComponent, UIFontConfig } from '../base/ui-component'; import { BoxModel, MeasuredSize } from '../base/box-model'; import { EdgeInsets } from '../base/edge-insets'; import { LayoutEngine } from '../services'; @@ -43,6 +43,11 @@ export interface UIScrollContainerProps { * (default: 0) */ scrollPaddingBottom?: number; + /** + * Font configuration that children will inherit (like CSS). + * Allows setting fontFamily, fontSize, etc. once at container level. + */ + fontConfig?: UIFontConfig; } /** @@ -96,6 +101,10 @@ export class UIScrollContainer extends UIComponent { constructor(props: UIScrollContainerProps, boxModel?: Partial) { super(boxModel); + // Set font config if provided (children will inherit this) + if (props.fontConfig) { + this.setFontConfig(props.fontConfig); + } this.props = { width: props.width, @@ -469,8 +478,8 @@ export class UIScrollContainer extends UIComponent { * Adds a child to the scrollable content */ addChild(child: UIComponent): void { - // Set parent reference for scroll-into-view support - child.parent = this as any; + // Set parent reference for font inheritance and scroll-into-view support + child.parent = this; // Track child for focus manager discovery this.children.push(child); From 99605682c298bdbacef9f67b748249914212c525 Mon Sep 17 00:00:00 2001 From: Chad Neff Date: Sun, 7 Dec 2025 01:59:58 -0800 Subject: [PATCH 4/5] changelogs --- packages/core/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/ui/CHANGELOG.md | 10 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f02f30b..ff7841a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +#### setupMoxi +- `width` and `height` top-level props for convenient canvas sizing (alternative to `renderOptions.width/height`) +- `scaleMode` option ('none' | 'fit' | 'fill' | 'stretch') for automatic canvas scaling with CSS injection + +#### Entity Helpers +- `asEntityGraphics(props)` - Creates Graphics wrapped with `asEntity()` for Logic support +- `asEntitySprite(constructorArgs, props)` - Creates Sprite wrapped with `asEntity()` +- `asEntityContainer(props)` - Creates Container wrapped with `asEntity()` +- `asEntityText(constructorArgs, props)` - Creates Text wrapped with `asEntity()` + +### Changed + +#### ClientEvents +- Enhanced JSDoc documentation clarifying key format uses `event.key` (e.g., 'a', 'ArrowRight', ' ') not `event.code` +- Added common key values table and MDN reference link + +#### ActionManager +- Enhanced JSDoc with comprehensive examples for adding/removing listeners +- Documented common event targets (window, document, canvas) and event types +- Added cleanup pattern documentation for Logic components + ## [0.3.2] - 2025-12-06 ### Changed diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 12fe851..eb17f99 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -36,6 +36,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### FlexContainer - `removeAllChildren()` method for clearing all children at once +#### UIScrollContainer +- `fontConfig` prop for font inheritance to nested children + +#### UILabel +- Now uses `getInheritedFontFamily()` for proper font inheritance from parent containers + +### Fixed +- Font inheritance now works through deeply nested containers (FlexContainer → UIScrollContainer → UILabel) +- Removed unnecessary `as any` cast in UIScrollContainer parent assignment + ## [0.3.2] - 2025-12-06 ### Added From 86965d4fdf88f9c4cd64cee7384a097f6833d46e Mon Sep 17 00:00:00 2001 From: Chad Neff Date: Sun, 7 Dec 2025 02:13:45 -0800 Subject: [PATCH 5/5] broken stuff fixed --- packages/ui/src/components/ui-scroll-container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/ui-scroll-container.ts b/packages/ui/src/components/ui-scroll-container.ts index aaaf9c4..408d9e2 100644 --- a/packages/ui/src/components/ui-scroll-container.ts +++ b/packages/ui/src/components/ui-scroll-container.ts @@ -69,7 +69,7 @@ export interface UIScrollContainerProps { * ``` */ export class UIScrollContainer extends UIComponent { - private props: Required; + private props: Required>; private background: PIXI.Graphics; private contentContainer: PIXI.Container; private contentMask: PIXI.Graphics;