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
8 changes: 8 additions & 0 deletions .claude/skills/jgengine-gameplay/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
- `InspectionZoomAnchor` (type): type InspectionZoomAnchor = "target" | "cursor" | "center" — How scroll-zoom re-anchors the view for the inspection rig (#207.7): - `target` — dolly toward the orbit target (classic OrbitControls behavior). - `cursor` — dolly toward the point under the pointer. - `center` — dolly toward the viewport center; equivalent to `target` for an OrbitControls-driven rig, since the camera always faces `target` and that point already projects to the exact center of the viewport.
- `LockOnCameraConfig` (interface): interface LockOnCameraConfig — Lock-on / strafe rig (#26) — yaw bound to player→target, move axis becomes strafe.
- `ObserverCameraConfig` (interface): interface ObserverCameraConfig — Detached spectator/photo cam (#120) — binds to any entity or fixed point, never reads player input.
- `PixelPerfectCameraConfig` (interface): interface PixelPerfectCameraConfig — Pixel-art orthographic camera tuning. `pixelsPerUnit` is the authored pixel density.
- `PlayerFovConfig` (interface): interface PlayerFovConfig — Player-facing FOV preference applied across every perspective camera rig. Orthographic projections ignore it.
- `RtsCameraConfig` (interface): interface RtsCameraConfig extends TopDownCameraConfig — Free-pan / edge-scroll RTS rig (#24) — pan/rotate/zoom independent of any avatar.
- `ShoulderCameraConfig` (interface): interface ShoulderCameraConfig — Over-the-shoulder combat rig (#25) — offset, ADS, shoulder swap, decoupled reticle.
Expand Down Expand Up @@ -680,6 +681,13 @@
- `classifyPing` (function): function classifyPing(hit: PointerHit, deps: PingClassifyDeps = {}, options: PingClassifyOptions = {}): PingCategory — Classify what a pointer/aim ray hit into a ping category. Entity hits resolve by catalog role (hostile → enemy, else location); object hits by an optional catalog category tag; open ground is a location ping.
- `createPingSystem` (function): function createPingSystem(deps: PingSystemDeps): PingSystem — Contextual ping/marker communication between teammates, classified by what was pinged.

## @jgengine/core/game/pixelPerfect

- `PixelPerfectFrustum` (interface): interface PixelPerfectFrustum — The orthographic bounds and scale derived from a pixel-perfect camera config.
- `PixelPerfectViewport` (interface): interface PixelPerfectViewport — Viewport dimensions in device-independent canvas pixels.
- `pixelPerfectFrustum` (function): function pixelPerfectFrustum(viewport: PixelPerfectViewport, pixelsPerUnit: number, integerScale = false): PixelPerfectFrustum — Computes an orthographic frustum whose world units map to the requested pixel density. Integer scaling chooses the largest whole-number density multiplier available in the viewport.
- `snapPixelPerfectPosition` (function): function snapPixelPerfectPosition<T extends { x: number; y: number; z: number }>(position: T, pixelsPerUnit: number): T — Snaps a camera position to the authored pixel grid without changing its orientation.

## @jgengine/core/game/playableGame

- `AmbientLightingConfig` (interface): interface AmbientLightingConfig — ⚠ undocumented
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ between (`--json` for structured output).
- WS sessions now issue resume tickets and retain disconnected memberships for a 15-second grace window, allowing reconnects to rejoin without replacing player state.
- Shell entity sprites can play atlas-backed sprite clips while preserving raw texture sprites.
- Tilemap layers can render atlas-backed textured tiles as one instanced quad mesh with parallax.
- Core pixel-perfect orthographic frustum and pixel-grid camera snapping are available to shell cameras.
- Core sprite atlas adapters and deterministic 2D sprite clip playback primitives.
- `src/art-direction.md` scaffold and `check-art-direction` gate for created games.
- Host-authoritative shell sessions now expose each accepted world frame to the local prediction buffer, reconciling and snapping the possessed pose only when drift exceeds its threshold.
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/game/cameraConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export type CameraRigKind =
/** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default "perspective". */
export type CameraProjection = "perspective" | "orthographic";

/** Pixel-art orthographic camera tuning. `pixelsPerUnit` is the authored pixel density. */
export interface PixelPerfectCameraConfig {
pixelsPerUnit: number;
/** Increase density in whole-number steps to use the largest scale that fits the viewport. */
integerScale?: boolean;
}

/** Fixed lateral 2.5D follow (side-on platformer cam): the camera sits perpendicular to the travel axis, tracks the followed entity, and never reads player look input. */
export interface SideScrollCameraConfig {
/** World axis the action travels along; the camera watches from the perpendicular side. Default "x". */
Expand Down Expand Up @@ -313,6 +320,8 @@ export interface GameCameraConfig {
rig?: CameraRigKind;
/** Canvas camera projection. "orthographic" renders a flat 2D-style view (side-scrollers, falling-block puzzles) — pair with `rig: "sideScroll"`; default perspective. */
projection?: CameraProjection;
/** Pixel-perfect orthographic rendering; ignored for perspective cameras. */
pixelPerfect?: PixelPerfectCameraConfig;
/** Render frustum overrides applied to the canvas camera. `far` defaults to 300 — raise it for worlds whose content spans more than a few hundred units, or distant scenery silently clips. `zoom` is the orthographic zoom in canvas pixels per world unit, read only when `projection` is "orthographic"; default 50. */
frustum?: { fov?: number; near?: number; far?: number; zoom?: number };
/** Universal player FOV preference (slider + persistence) for perspective rigs. Ignored when `projection` is `"orthographic"`. */
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/game/pixelPerfect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import { pixelPerfectFrustum, snapPixelPerfectPosition } from "./pixelPerfect";

describe("pixelPerfectFrustum", () => {
test("maps viewport pixels to world units", () => {
expect(pixelPerfectFrustum({ width: 320, height: 180 }, 16)).toEqual({
left: -10,
right: 10,
top: 5.625,
bottom: -5.625,
zoom: 16,
scale: 1,
});
});

test("uses the largest integer density multiplier", () => {
const result = pixelPerfectFrustum({ width: 640, height: 360 }, 16, true);
expect(result.scale).toBe(22);
expect(result.zoom).toBe(352);
expect(result.right - result.left).toBeCloseTo(640 / 352);
});
});

test("snapPixelPerfectPosition rounds every camera axis to the pixel grid", () => {
expect(snapPixelPerfectPosition({ x: 0.11, y: 1.24, z: -2.17 }, 10)).toEqual({ x: 0.1, y: 1.2, z: -2.2 });
});
53 changes: 53 additions & 0 deletions packages/core/src/game/pixelPerfect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Viewport dimensions in device-independent canvas pixels. */
export interface PixelPerfectViewport {
width: number;
height: number;
}

/** The orthographic bounds and scale derived from a pixel-perfect camera config. */
export interface PixelPerfectFrustum {
left: number;
right: number;
top: number;
bottom: number;
zoom: number;
scale: number;
}

/**
* Computes an orthographic frustum whose world units map to the requested pixel density.
* Integer scaling chooses the largest whole-number density multiplier available in the viewport.
*/
export function pixelPerfectFrustum(
viewport: PixelPerfectViewport,
pixelsPerUnit: number,
integerScale = false,
): PixelPerfectFrustum {
const width = Math.max(1, viewport.width);
const height = Math.max(1, viewport.height);
const density = Math.max(1e-6, pixelsPerUnit);
const scale = integerScale ? Math.max(1, Math.floor(Math.min(width, height) / density)) : 1;
const zoom = density * scale;
return {
left: -width / (2 * zoom),
right: width / (2 * zoom),
top: height / (2 * zoom),
bottom: -height / (2 * zoom),
zoom,
scale,
};
}

/** Snaps a camera position to the authored pixel grid without changing its orientation. */
export function snapPixelPerfectPosition<T extends { x: number; y: number; z: number }>(
position: T,
pixelsPerUnit: number,
): T {
const density = Math.max(1e-6, pixelsPerUnit);
return {
...position,
x: Math.round(position.x * density) / density,
y: Math.round(position.y * density) / density,
z: Math.round(position.z * density) / density,
};
}
24 changes: 24 additions & 0 deletions packages/shell/src/Shell3dPresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type RefObject,
type SetStateAction,
} from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { OrthographicCamera } from "three";

import {
contextVerbInput,
Expand All @@ -33,6 +35,7 @@ import type { SceneEntity } from "@jgengine/core/scene/entityStore";
import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
import type { PointerConfig } from "@jgengine/core/game/playableGame";
import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
import { pixelPerfectFrustum, snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
import type { GameSettingsConfig } from "@jgengine/core/settings/settingsModel";
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
import {
Expand Down Expand Up @@ -100,6 +103,24 @@ import {
const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
const DEFAULT_BACKGROUND_COLOR = "#14161b";

function PixelPerfectCamera({ pixelsPerUnit, integerScale }: { pixelsPerUnit: number; integerScale?: boolean }) {
const camera = useThree((state) => state.camera) as OrthographicCamera;
const size = useThree((state) => state.size);
useFrame(() => {
if (camera.isOrthographicCamera !== true) return;
const frustum = pixelPerfectFrustum(size, pixelsPerUnit, integerScale === true);
camera.left = frustum.left;
camera.right = frustum.right;
camera.top = frustum.top;
camera.bottom = frustum.bottom;
camera.zoom = 1;
const snapped = snapPixelPerfectPosition(camera.position, pixelsPerUnit);
camera.position.set(snapped.x, snapped.y, snapped.z);
camera.updateProjectionMatrix();
}, 1);
return null;
}

/** 3D play surface: canvas, world overlays, and shared chrome. @internal */
export function Shell3dPresentation({
playable,
Expand Down Expand Up @@ -498,6 +519,9 @@ export function Shell3dPresentation({
gl={{ preserveDrawingBuffer: true }}
style={{ touchAction: "none" }}
>
{orthographic && playable.camera?.pixelPerfect !== undefined ? (
<PixelPerfectCamera {...playable.camera.pixelPerfect} />
) : null}
{backgroundColor !== undefined ? <color attach="background" args={[backgroundColor]} /> : null}
{cinematicLook ? (
<EnvironmentLighting
Expand Down
7 changes: 7 additions & 0 deletions packages/shell/src/camera/cameraRigs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ShoulderCameraConfig,
SideScrollCameraConfig,
} from "@jgengine/core/game/playableGame";
import { snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
import type { GameContext } from "@jgengine/core/runtime/gameContext";
import { usePlayer } from "@jgengine/react/hooks";
import { useGameContext } from "@jgengine/react/provider";
Expand Down Expand Up @@ -260,6 +261,12 @@ export function SideScrollRig(props: RigProps) {
};
followRef.current = follow;
const pose = resolveSideScrollPose(follow, resolved, config?.fov ?? currentFov(camera));
const pixelPerfect = props.config?.projection === "orthographic" ? props.config.pixelPerfect : undefined;
if (pixelPerfect !== undefined) {
const snapped = snapPixelPerfectPosition(pose.position, pixelPerfect.pixelsPerUnit);
pose.position = snapped;
pose.lookAt = snapPixelPerfectPosition(pose.lookAt, pixelPerfect.pixelsPerUnit);
}
commit(pose, dt);
}, CAMERA_RIG_FRAME_PRIORITY);

Expand Down
1 change: 1 addition & 0 deletions scripts/export-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"./game/paramLayers",
"./game/partAnimation",
"./game/ping",
"./game/pixelPerfect",
"./game/playableGame",
"./game/progression",
"./game/quest",
Expand Down
Loading