Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/jgengine-ui/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions changes/world-bar-scale.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 24 additions & 2 deletions packages/shell/src/world/worldBarSamples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 });
});
});
54 changes: 45 additions & 9 deletions packages/shell/src/world/worldBarSamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading