diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index d25063238..c087b6ad5 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -3147,7 +3147,7 @@ - `NameplateSample` (interface): interface NameplateSample — Projected nameplate with optional health and distance from the render camera. - `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented - `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented -- `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number } — ⚠ undocumented +- `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number; pxPerMeter?: number } — ⚠ undocumented - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond … — ⚠ undocumented - `WorldFloatText` (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element — ⚠ undocumented - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand. @@ -3199,7 +3199,7 @@ - `NameplateSample` (interface): interface NameplateSample — Projected nameplate with optional health and distance from the render camera. - `Projectable` (interface): interface Projectable { set(x: number, y: number, z: number): this; project(camera: {matrixWorldInverse: unknown; projectionMatrix: unknown}): this; x: number; y: number; z: number } — ⚠ undocumented -- `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number } — ⚠ undocumented +- `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number; pxPerMeter?: number } — ⚠ undocumented - `WorldOverlayCamera` (interface): interface WorldOverlayCamera — Render camera matrices; world translation supplies the overlay's viewpoint. - `WorldOverlaySampleOptions` (interface): interface WorldOverlaySampleOptions — Sampling policy shared by health bars and nameplates. diff --git a/changes/world-bar-scale.md b/changes/world-bar-scale.md new file mode 100644 index 000000000..6491d0621 --- /dev/null +++ b/changes/world-bar-scale.md @@ -0,0 +1,3 @@ +### Fixed + +- Built-in world health bars (`worldHealthBars`, `@jgengine/shell`) span about 1.4 m at the entity's on-screen scale (28–112 px) instead of a fixed 112 px, so top-down and RTS cameras no longer paint bars ten times wider than the units under them. diff --git a/packages/shell/src/world/worldBarSamples.test.ts b/packages/shell/src/world/worldBarSamples.test.ts index c9b7ff6af..f35a00c43 100644 --- a/packages/shell/src/world/worldBarSamples.test.ts +++ b/packages/shell/src/world/worldBarSamples.test.ts @@ -7,7 +7,7 @@ import { defineGameDefinition } from "@jgengine/core/game/defineGame"; import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog"; import { createPossession } from "@jgengine/core/scene/possession"; import { building, environment } from "@jgengine/core/world/features"; -import { collectNameplateSamples, collectWorldBarSamples, worldBarOccluded, refreshWorldBarSamples, type NameplateSample, type WorldBarSample, type WorldOverlaySampleOptions } from "./worldBarSamples"; +import { collectNameplateSamples, collectWorldBarSamples, worldBarOccluded, worldBarSize, refreshWorldBarSamples, type NameplateSample, type WorldBarSample, type WorldOverlaySampleOptions } from "./worldBarSamples"; function setup(deps: SceneRaycastDeps = {}) { const store = createEntityStore(); @@ -110,7 +110,7 @@ describe("world overlay visibility", () => { const { ctx, spawn, rays } = setup(); spawn("enemy", [0, 0, 0], "Wolf"); spawn("npc", [1, 0, 0], "Innkeeper", null); - expect(bars(ctx)).toEqual([{ entityId: "enemy", x: 400, y: 300, percent: 0.5 }]); + expect(bars(ctx)).toEqual([{ entityId: "enemy", x: 400, y: 300, percent: 0.5, pxPerMeter: expect.any(Number) }]); expect(plates(ctx).map((p) => [p.name, p.percent])).toEqual([["Wolf", 0.5], ["Innkeeper", null]]); expect(rays.every((r) => r.filter?.entities === false)).toBe(true); }); @@ -218,3 +218,25 @@ describe("world overlay visibility", () => { expect(rays).toHaveLength(76); }); }); + +describe("world bar size", () => { + test("a far or zoomed-out actor gets a smaller bar than a near one", () => { + const { ctx, spawn } = setup(); + spawn("enemy", [0, 0, 0], "Wolf"); + const near = bars(ctx, cameraAt([0, 2, 6]))[0]!.pxPerMeter!; + const far = bars(ctx, cameraAt([0, 2, 40]))[0]!.pxPerMeter!; + expect(near).toBeGreaterThan(far * 4); + expect(worldBarSize(near).width).toBeGreaterThan(worldBarSize(far).width); + }); + + test("spans about 1.4 m on screen, clamped between a readable minimum and the maximum", () => { + expect(worldBarSize(40)).toEqual({ width: 56, height: 5 }); + expect(worldBarSize(2)).toEqual({ width: 28, height: 4 }); + expect(worldBarSize(500)).toEqual({ width: 112, height: 10 }); + }); + + test("caller-painted samples without a scale keep the configured size", () => { + expect(worldBarSize(undefined)).toEqual({ width: 112, height: 10 }); + expect(worldBarSize(undefined, 80, 8)).toEqual({ width: 80, height: 8 }); + }); +}); diff --git a/packages/shell/src/world/worldBarSamples.ts b/packages/shell/src/world/worldBarSamples.ts index 52d04a65a..96753c906 100644 --- a/packages/shell/src/world/worldBarSamples.ts +++ b/packages/shell/src/world/worldBarSamples.ts @@ -8,6 +8,8 @@ export interface WorldBarSample { x: number; y: number; percent: number; + /** Screen pixels per world metre at the anchor, so painted bars shrink with distance and zoom. */ + pxPerMeter?: number; } export interface Projectable { @@ -71,6 +73,19 @@ function projectAnchor(entity: SceneEntity, height: number, camera: WorldOverlay return anchor; } +/** + * Screen pixels per world metre at `anchor`: project a point one metre along the camera's right + * axis and measure the gap. Call after reading the anchor's own projection — `project` is reused. + */ +function pixelsPerMeter(anchor: EntityPosition, camera: WorldOverlayCamera, project: Projectable, viewport: { width: number; height: number }, x: number, y: number): number { + const m = camera.matrixWorld.elements; + project.set(anchor[0] + m[0]!, anchor[1] + m[1]!, anchor[2] + m[2]!).project(camera); + const px = (project.x * 0.5 + 0.5) * viewport.width; + const py = (-project.y * 0.5 + 0.5) * viewport.height; + const scale = Math.hypot(px - x, py - y); + return Number.isFinite(scale) ? scale : 0; +} + function healthFraction(stat: { min: number; max: number; current: number }): number { const range = stat.max - stat.min; return range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range)); @@ -118,11 +133,14 @@ export function collectWorldBarSamples( if (anchor === null) continue; if (checked++ >= limit) break; if (occlude && worldBarOccluded(ctx, origin, anchor)) continue; + const x = (project.x * 0.5 + 0.5) * viewport.width; + const y = (-project.y * 0.5 + 0.5) * viewport.height; into.push({ entityId: id, - x: (project.x * 0.5 + 0.5) * viewport.width, - y: (-project.y * 0.5 + 0.5) * viewport.height, + x, + y, percent: healthFraction(stat), + pxPerMeter: pixelsPerMeter(anchor, camera, project, viewport, x, y), }); } return into.length; @@ -145,9 +163,11 @@ export function refreshWorldBarSamples( if (sample.entityId === undefined) continue; const entity = eligibleEntity(ctx, sample.entityId, options); if (entity === null || Math.hypot(entity.position[0] - origin[0], entity.position[1] - origin[1], entity.position[2] - origin[2]) > maxDistance) continue; - if (projectAnchor(entity, height, camera, project) === null) continue; + const anchor = projectAnchor(entity, height, camera, project); + if (anchor === null) continue; sample.x = (project.x * 0.5 + 0.5) * viewport.width; sample.y = (-project.y * 0.5 + 0.5) * viewport.height; + sample.pxPerMeter = pixelsPerMeter(anchor, camera, project, viewport, sample.x, sample.y); samples[kept++] = sample; } samples.length = kept; @@ -212,24 +232,40 @@ export function collectNameplateSamples( return into.length; } +/** World width a health bar spans over its entity, in metres. */ +const WORLD_BAR_METERS = 1.4; +const MIN_BAR_WIDTH_PX = 28; + +/** + * Painted bar size for a sample: about {@link WORLD_BAR_METERS} wide at the anchor's on-screen + * scale, clamped between a still-readable minimum and the configured maximum, with height in + * proportion. Samples without a scale (caller-painted screen samples) keep the maximum. + * @internal + */ +export function worldBarSize(pxPerMeter: number | undefined, maxWidthPx = 112, maxHeightPx = 10): { width: number; height: number } { + if (pxPerMeter === undefined || !(pxPerMeter > 0)) return { width: maxWidthPx, height: maxHeightPx }; + const width = Math.max(Math.min(MIN_BAR_WIDTH_PX, maxWidthPx), Math.min(maxWidthPx, pxPerMeter * WORLD_BAR_METERS)); + const height = Math.max(4, Math.min(maxHeightPx, Math.round(width / 11))); + return { width: Math.round(width), height }; +} + /** @internal */ export function paintWorldBarSamples( canvas: { width: number; height: number; getContext(kind: "2d"): CanvasRenderingContext2D | null }, samples: readonly WorldBarSample[], dpr: number, - barWidthPx = 112, - barHeightPx = 10, + maxBarWidthPx = 112, + maxBarHeightPx = 10, ): void { const g = canvas.getContext("2d"); if (g === null) return; g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, canvas.width, canvas.height); g.setTransform(dpr, 0, 0, dpr, 0, 0); - const halfW = barWidthPx / 2; - const halfH = barHeightPx / 2; for (const sample of samples) { - const left = sample.x - halfW; - const top = sample.y - halfH; + const { width: barWidthPx, height: barHeightPx } = worldBarSize(sample.pxPerMeter, maxBarWidthPx, maxBarHeightPx); + const left = sample.x - barWidthPx / 2; + const top = sample.y - barHeightPx / 2; g.fillStyle = "rgba(0,0,0,0.7)"; g.strokeStyle = "rgba(0,0,0,0.7)"; g.lineWidth = 1;