@waica/engine 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +24 -0
  2. package/dist/components/animated-sprite.d.ts +6 -0
  3. package/dist/components/animated-sprite.js +15 -1
  4. package/dist/components/light.d.ts +68 -0
  5. package/dist/components/light.js +59 -0
  6. package/dist/components/particle-emitter.d.ts +6 -0
  7. package/dist/components/particle-emitter.js +13 -0
  8. package/dist/components/sprite.d.ts +6 -0
  9. package/dist/components/sprite.js +15 -1
  10. package/dist/components/tilemap.js +6 -0
  11. package/dist/frame-quad.d.ts +21 -0
  12. package/dist/frame-quad.js +50 -0
  13. package/dist/game.d.ts +8 -0
  14. package/dist/game.js +20 -8
  15. package/dist/index.d.ts +11 -1
  16. package/dist/index.js +6 -0
  17. package/dist/light-field.d.ts +78 -0
  18. package/dist/light-field.js +145 -0
  19. package/dist/light-map-material.d.ts +40 -0
  20. package/dist/light-map-material.js +203 -0
  21. package/dist/light-map.d.ts +46 -0
  22. package/dist/light-map.js +132 -0
  23. package/dist/lit-frame.d.ts +48 -0
  24. package/dist/lit-frame.js +167 -0
  25. package/dist/occluder-grid.d.ts +19 -0
  26. package/dist/occluder-grid.js +50 -0
  27. package/dist/particle-batch.js +3 -2
  28. package/dist/post-effects.d.ts +33 -0
  29. package/dist/post-effects.js +48 -0
  30. package/dist/post-pass.d.ts +23 -0
  31. package/dist/post-pass.js +93 -0
  32. package/dist/render-layers.d.ts +18 -0
  33. package/dist/render-layers.js +19 -0
  34. package/dist/render-output.d.ts +7 -0
  35. package/dist/render-output.js +17 -1
  36. package/dist/runtime-inspection.d.ts +5 -0
  37. package/dist/runtime-inspection.js +5 -1
  38. package/dist/runtime-lighting-snapshot.d.ts +41 -0
  39. package/dist/runtime-lighting-snapshot.js +46 -0
  40. package/dist/scene-lighting.d.ts +52 -0
  41. package/dist/scene-lighting.js +107 -0
  42. package/dist/scene-render-options.d.ts +51 -0
  43. package/dist/scene-render-options.js +126 -0
  44. package/dist/scene.d.ts +29 -0
  45. package/dist/sprite-batch.js +5 -1
  46. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ import type { Entity } from './entity.js';
2
+ import type { Game } from './game.js';
3
+ import type { ProjectionIssue } from './runtime-inspection.js';
4
+ import type { AmbientLight, PostEffectsState } from './scene-render-options.js';
5
+ /** One live Light as a Runtime Snapshot reports it: its entity's name, logical position and params. */
6
+ export interface RuntimeSnapshotLight {
7
+ /** The entity's name, as `ui.anchored[].entity` reports it. */
8
+ entity: string;
9
+ /** The entity's snapshot id (`entity-N`), unique where names repeat. */
10
+ id: string;
11
+ x: number;
12
+ y: number;
13
+ radius: number;
14
+ /** `#rrggbb`. */
15
+ color: string;
16
+ intensity: number;
17
+ bands: number;
18
+ softness: number;
19
+ castShadows: boolean;
20
+ }
21
+ /** `game.lighting` (issue #78 CA-12): the Ambient Light now and every live Light, in logical coordinates. */
22
+ export interface RuntimeSnapshotLighting {
23
+ ambient: AmbientLight;
24
+ lights: RuntimeSnapshotLight[];
25
+ }
26
+ /** `game.post` (issue #78 CA-12): each Post Effect, null when off. */
27
+ export type RuntimeSnapshotPost = PostEffectsState;
28
+ export declare function lightingSnapshot(game: Game, idFor: (entity: Entity) => string): RuntimeSnapshotLighting;
29
+ export declare function postSnapshot(game: Game): RuntimeSnapshotPost;
30
+ /** What capping the lights needs from a snapshot: its lights and its projection issues. */
31
+ interface LightsCappable {
32
+ lighting: RuntimeSnapshotLighting;
33
+ projectionIssues: ProjectionIssue[];
34
+ }
35
+ /**
36
+ * The global cap's last stage, once entities and Anchored Pieces are gone
37
+ * (review): drops lights from the end until the snapshot fits, recording how
38
+ * many went with a `truncated` marker at `lighting.lights[n]`.
39
+ */
40
+ export declare function capLights<T extends LightsCappable>(snapshot: T, fits: (candidate: T) => boolean): T;
41
+ export {};
@@ -0,0 +1,46 @@
1
+ const hex = (channel) => Math.round(channel * 255).toString(16).padStart(2, '0');
2
+ export function lightingSnapshot(game, idFor) {
3
+ return {
4
+ ambient: game.lighting.ambient,
5
+ lights: game.lighting.lights.map((light) => {
6
+ const field = light.field();
7
+ const [r, g, b] = field.color;
8
+ return {
9
+ entity: light.entity.name,
10
+ id: idFor(light.entity),
11
+ x: field.x,
12
+ y: field.y,
13
+ radius: field.radius,
14
+ color: `#${hex(r)}${hex(g)}${hex(b)}`,
15
+ intensity: field.intensity,
16
+ bands: field.bands,
17
+ softness: field.softness,
18
+ castShadows: field.castShadows,
19
+ };
20
+ }),
21
+ };
22
+ }
23
+ export function postSnapshot(game) {
24
+ return { vignette: game.post.vignette, colorGrade: game.post.colorGrade };
25
+ }
26
+ /**
27
+ * The global cap's last stage, once entities and Anchored Pieces are gone
28
+ * (review): drops lights from the end until the snapshot fits, recording how
29
+ * many went with a `truncated` marker at `lighting.lights[n]`.
30
+ */
31
+ export function capLights(snapshot, fits) {
32
+ let capped = snapshot;
33
+ const retained = [...snapshot.lighting.lights];
34
+ while (retained.length > 0) {
35
+ retained.pop();
36
+ const omitted = snapshot.lighting.lights.length - retained.length;
37
+ capped = {
38
+ ...snapshot,
39
+ lighting: { ...snapshot.lighting, lights: [...retained] },
40
+ projectionIssues: [...snapshot.projectionIssues, { path: `lighting.lights[${retained.length}]`, marker: 'truncated', omitted }],
41
+ };
42
+ if (fits(capped))
43
+ return capped;
44
+ }
45
+ return capped;
46
+ }
@@ -0,0 +1,52 @@
1
+ import type { Light } from './components/light.js';
2
+ import type { Rgb } from './light-field.js';
3
+ import { type AmbientLight } from './scene-render-options.js';
4
+ import type { SceneRenderJson } from './scene.js';
5
+ /** What `game.lighting.ambient` accepts: either field, a color as `#rrggbb` or a number. */
6
+ export interface AmbientLightInput {
7
+ color?: string | number;
8
+ intensity?: number;
9
+ }
10
+ /** A `#rrggbb` color's three channels in 0..1. */
11
+ export declare function hexChannels(hex: string): Rgb;
12
+ /**
13
+ * `game.lighting` (issue #78, ADR 0026): the live scene's Ambient Light and
14
+ * its Lights. A scene is drawn lit while it declares `render.lighting`, a
15
+ * game sets the ambient at runtime, or a Light lives in it; otherwise it is
16
+ * drawn exactly as an unlit scene. Everything here dies with its scene.
17
+ */
18
+ export declare class GameLighting {
19
+ constructor();
20
+ /** The Ambient Light now, a copy: color `#rrggbb`, intensity 0..1. */
21
+ get ambient(): AmbientLight;
22
+ /**
23
+ * Changes the Ambient Light, field by field — tween it with `game.time.tween`
24
+ * for a day/night cycle. Out-of-range values clamp; an unreadable color is
25
+ * ignored. Setting it lights a scene that declared no lighting.
26
+ */
27
+ set ambient(value: AmbientLightInput);
28
+ /** Whether the live scene is drawn lit. */
29
+ get active(): boolean;
30
+ /** Every live Light, in the order they were spawned. */
31
+ get lights(): readonly Light[];
32
+ /** Adopts a scene's `render.lighting`, replacing whatever the previous scene left. Called by Game. */
33
+ loadScene(render: SceneRenderJson | undefined): void;
34
+ /** Back to an unlit scene's state: full white, undeclared. Its Lights leave with their entities. */
35
+ unloadScene(): void;
36
+ }
37
+ /** The Ambient Light as light-map multipliers: its color's channels × its intensity. */
38
+ export declare function ambientMultiplier(lighting: GameLighting): Rgb;
39
+ /**
40
+ * Whether the Ambient Light leaves the art as drawn: lights only add and the
41
+ * light-map clamps at 1, so such a scene's light-map is 1 everywhere and the
42
+ * frame is drawn unlit, building nothing (review).
43
+ */
44
+ export declare function ambientIsFull(lighting: GameLighting): boolean;
45
+ /** Internal: a Light joins its scene's lighting when it is ready. */
46
+ export declare function addLight(lighting: GameLighting, light: Light): void;
47
+ /** Internal: a destroyed Light leaves at once, so the next drawn frame no longer has it. */
48
+ export declare function removeLight(lighting: GameLighting, light: Light): void;
49
+ /** Internal: a Tilemap's solid tiles changed (spawned, edited or destroyed); occlusion is rebuilt once. */
50
+ export declare function markOccludersChanged(lighting: GameLighting | undefined): void;
51
+ /** Internal: bumps whenever the solid tiles change, so the occluder grid is rebuilt only then (inference 9). */
52
+ export declare function occluderRevision(lighting: GameLighting): number;
@@ -0,0 +1,107 @@
1
+ import { hexColorOr, resolveAmbientLight } from './scene-render-options.js';
2
+ const states = new WeakMap();
3
+ function stateOf(lighting) {
4
+ const state = states.get(lighting);
5
+ if (!state)
6
+ throw new Error('a GameLighting was used before it was constructed');
7
+ return state;
8
+ }
9
+ /** `0xrrggbb` as `#rrggbb`; anything that is not a 24-bit integer as undefined. */
10
+ function hexOfNumber(value) {
11
+ if (!Number.isInteger(value) || value < 0 || value > 0xffffff)
12
+ return undefined;
13
+ return `#${value.toString(16).padStart(6, '0')}`;
14
+ }
15
+ /** A `#rrggbb` color's three channels in 0..1. */
16
+ export function hexChannels(hex) {
17
+ const value = Number.parseInt(hex.slice(1), 16);
18
+ return [((value >> 16) & 0xff) / 255, ((value >> 8) & 0xff) / 255, (value & 0xff) / 255];
19
+ }
20
+ /**
21
+ * `game.lighting` (issue #78, ADR 0026): the live scene's Ambient Light and
22
+ * its Lights. A scene is drawn lit while it declares `render.lighting`, a
23
+ * game sets the ambient at runtime, or a Light lives in it; otherwise it is
24
+ * drawn exactly as an unlit scene. Everything here dies with its scene.
25
+ */
26
+ export class GameLighting {
27
+ constructor() {
28
+ states.set(this, { ambient: resolveAmbientLight(undefined), declared: false, lights: [], occluderRevision: 0 });
29
+ }
30
+ /** The Ambient Light now, a copy: color `#rrggbb`, intensity 0..1. */
31
+ get ambient() {
32
+ const { ambient } = stateOf(this);
33
+ return { color: ambient.color, intensity: ambient.intensity };
34
+ }
35
+ /**
36
+ * Changes the Ambient Light, field by field — tween it with `game.time.tween`
37
+ * for a day/night cycle. Out-of-range values clamp; an unreadable color is
38
+ * ignored. Setting it lights a scene that declared no lighting.
39
+ */
40
+ set ambient(value) {
41
+ const state = stateOf(this);
42
+ const color = typeof value.color === 'number' ? hexOfNumber(value.color) : value.color;
43
+ // An unreadable intensity (a NaN from a custom easing, say) is ignored, like an unreadable color.
44
+ const intensity = typeof value.intensity === 'number' && Number.isFinite(value.intensity) ? value.intensity : state.ambient.intensity;
45
+ state.ambient = resolveAmbientLight({
46
+ ambient: { color: hexColorOr(color, state.ambient.color), intensity },
47
+ });
48
+ state.declared = true;
49
+ }
50
+ /** Whether the live scene is drawn lit. */
51
+ get active() {
52
+ const state = stateOf(this);
53
+ return state.declared || state.lights.length > 0;
54
+ }
55
+ /** Every live Light, in the order they were spawned. */
56
+ get lights() {
57
+ return [...stateOf(this).lights];
58
+ }
59
+ /** Adopts a scene's `render.lighting`, replacing whatever the previous scene left. Called by Game. */
60
+ loadScene(render) {
61
+ const state = stateOf(this);
62
+ state.ambient = resolveAmbientLight(render?.lighting);
63
+ state.declared = render?.lighting !== undefined;
64
+ }
65
+ /** Back to an unlit scene's state: full white, undeclared. Its Lights leave with their entities. */
66
+ unloadScene() {
67
+ this.loadScene(undefined);
68
+ }
69
+ }
70
+ /** The Ambient Light as light-map multipliers: its color's channels × its intensity. */
71
+ export function ambientMultiplier(lighting) {
72
+ const { color, intensity } = stateOf(lighting).ambient;
73
+ const [r, g, b] = hexChannels(color);
74
+ return [r * intensity, g * intensity, b * intensity];
75
+ }
76
+ /**
77
+ * Whether the Ambient Light leaves the art as drawn: lights only add and the
78
+ * light-map clamps at 1, so such a scene's light-map is 1 everywhere and the
79
+ * frame is drawn unlit, building nothing (review).
80
+ */
81
+ export function ambientIsFull(lighting) {
82
+ return ambientMultiplier(lighting).every((channel) => channel >= 1);
83
+ }
84
+ /** Internal: a Light joins its scene's lighting when it is ready. */
85
+ export function addLight(lighting, light) {
86
+ const { lights } = stateOf(lighting);
87
+ if (!lights.includes(light))
88
+ lights.push(light);
89
+ }
90
+ /** Internal: a destroyed Light leaves at once, so the next drawn frame no longer has it. */
91
+ export function removeLight(lighting, light) {
92
+ const { lights } = stateOf(lighting);
93
+ const index = lights.indexOf(light);
94
+ if (index !== -1)
95
+ lights.splice(index, 1);
96
+ }
97
+ /** Internal: a Tilemap's solid tiles changed (spawned, edited or destroyed); occlusion is rebuilt once. */
98
+ export function markOccludersChanged(lighting) {
99
+ // A Tilemap on a stand-in game (a test stub with no lighting) has nothing to notify.
100
+ const state = lighting ? states.get(lighting) : undefined;
101
+ if (state)
102
+ state.occluderRevision += 1;
103
+ }
104
+ /** Internal: bumps whenever the solid tiles change, so the occluder grid is rebuilt only then (inference 9). */
105
+ export function occluderRevision(lighting) {
106
+ return stateOf(lighting).occluderRevision;
107
+ }
@@ -0,0 +1,51 @@
1
+ import type { SceneLightingJson, ScenePostJson } from './scene.js';
2
+ /** The Ambient Light: a `#rrggbb` color and an intensity in 0..1. */
3
+ export interface AmbientLight {
4
+ color: string;
5
+ intensity: number;
6
+ }
7
+ export interface VignetteEffect {
8
+ /** How dark the corners get: 0 none, 1 black. */
9
+ intensity: number;
10
+ /** Where the darkening starts, from the centre (0) to the corners (1). */
11
+ radius: number;
12
+ }
13
+ export interface ColorGradeEffect {
14
+ /** A `#rrggbb` multiplier over the frame. */
15
+ tint: string;
16
+ /** 1 leaves contrast unchanged; 0 flattens to grey, 2 doubles it. */
17
+ contrast: number;
18
+ /** 1 leaves saturation unchanged; 0 is greyscale, 2 doubles it. */
19
+ saturation: number;
20
+ }
21
+ /** The scene's Post Effects; null is an effect that is off. */
22
+ export interface PostEffectsState {
23
+ vignette: VignetteEffect | null;
24
+ colorGrade: ColorGradeEffect | null;
25
+ }
26
+ /** One field of a scene's `render` block that is outside its range, for validate_project. */
27
+ export interface SceneRenderIssue {
28
+ field: string;
29
+ message: string;
30
+ }
31
+ /** A `#rrggbb` color in lower case, or `fallback` for anything else. */
32
+ export declare function hexColorOr(value: unknown, fallback: string): string;
33
+ /**
34
+ * The scene's starting Ambient Light: full white light unless `render.lighting.ambient`
35
+ * says otherwise. Runtime clamping (inference 11): an out-of-range value is
36
+ * clamped and an unreadable one falls back to its default, never thrown.
37
+ */
38
+ export declare function resolveAmbientLight(json: SceneLightingJson | undefined): AmbientLight;
39
+ /** A vignette as the runtime applies it, clamped; null when the scene has none. */
40
+ export declare function resolveVignette(value: unknown): VignetteEffect | null;
41
+ /** A color grade as the runtime applies it, absent fields neutral; null when the scene has none. */
42
+ export declare function resolveColorGrade(value: unknown): ColorGradeEffect | null;
43
+ /** The scene's starting Post Effects: every one off unless `render.post` turns it on. */
44
+ export declare function resolvePostEffects(json: ScenePostJson | undefined): PostEffectsState;
45
+ /**
46
+ * Every field of a scene's `render.lighting` and `render.post` that is
47
+ * outside its range, each with a message naming the field and its range —
48
+ * what the MCP scene validation reports (CA-2). Other render fields are not
49
+ * checked here.
50
+ */
51
+ export declare function sceneRenderIssues(render: unknown): SceneRenderIssue[];
@@ -0,0 +1,126 @@
1
+ const WHITE = '#ffffff';
2
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
3
+ /** Each numeric field's range (inference 12). */
4
+ const RANGES = {
5
+ 'render.lighting.ambient.intensity': [0, 1],
6
+ 'render.post.vignette.intensity': [0, 1],
7
+ 'render.post.vignette.radius': [0, 1],
8
+ 'render.post.colorGrade.contrast': [0, 2],
9
+ 'render.post.colorGrade.saturation': [0, 2],
10
+ };
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ function recordOf(value) {
15
+ return isRecord(value) ? value : {};
16
+ }
17
+ /** A `#rrggbb` color in lower case, or `fallback` for anything else. */
18
+ export function hexColorOr(value, fallback) {
19
+ return typeof value === 'string' && HEX_COLOR.test(value) ? value.toLowerCase() : fallback;
20
+ }
21
+ /** A finite number clamped to the field's range, or `fallback` for anything else. */
22
+ function rangedOr(value, field, fallback) {
23
+ if (typeof value !== 'number' || !Number.isFinite(value))
24
+ return fallback;
25
+ const [min, max] = RANGES[field];
26
+ return Math.min(max, Math.max(min, value));
27
+ }
28
+ /**
29
+ * The scene's starting Ambient Light: full white light unless `render.lighting.ambient`
30
+ * says otherwise. Runtime clamping (inference 11): an out-of-range value is
31
+ * clamped and an unreadable one falls back to its default, never thrown.
32
+ */
33
+ export function resolveAmbientLight(json) {
34
+ const ambient = recordOf(recordOf(json).ambient);
35
+ return {
36
+ color: hexColorOr(ambient.color, WHITE),
37
+ intensity: rangedOr(ambient.intensity, 'render.lighting.ambient.intensity', 1),
38
+ };
39
+ }
40
+ /** A vignette as the runtime applies it, clamped; null when the scene has none. */
41
+ export function resolveVignette(value) {
42
+ if (!isRecord(value))
43
+ return null;
44
+ return {
45
+ intensity: rangedOr(value.intensity, 'render.post.vignette.intensity', 0.5),
46
+ radius: rangedOr(value.radius, 'render.post.vignette.radius', 0.5),
47
+ };
48
+ }
49
+ /** A color grade as the runtime applies it, absent fields neutral; null when the scene has none. */
50
+ export function resolveColorGrade(value) {
51
+ if (!isRecord(value))
52
+ return null;
53
+ return {
54
+ tint: hexColorOr(value.tint, WHITE),
55
+ contrast: rangedOr(value.contrast, 'render.post.colorGrade.contrast', 1),
56
+ saturation: rangedOr(value.saturation, 'render.post.colorGrade.saturation', 1),
57
+ };
58
+ }
59
+ /** The scene's starting Post Effects: every one off unless `render.post` turns it on. */
60
+ export function resolvePostEffects(json) {
61
+ const post = recordOf(json);
62
+ return { vignette: resolveVignette(post.vignette), colorGrade: resolveColorGrade(post.colorGrade) };
63
+ }
64
+ const shown = (value) => (typeof value === 'string' ? JSON.stringify(value) : String(value));
65
+ function rangeIssue(field, value, issues) {
66
+ const [min, max] = RANGES[field];
67
+ if (typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max)
68
+ return;
69
+ issues.push({ field, message: `${field} must be a number from ${min} to ${max}; got ${shown(value)}.` });
70
+ }
71
+ function colorIssue(field, value, issues) {
72
+ if (value === undefined || (typeof value === 'string' && HEX_COLOR.test(value)))
73
+ return;
74
+ issues.push({ field, message: `${field} must be a #rrggbb color; got ${shown(value)}.` });
75
+ }
76
+ /** The block at `field` (its last segment, in `parent`), reported when present but not an object. */
77
+ function block(parent, field, issues) {
78
+ const value = parent[field.slice(field.lastIndexOf('.') + 1)];
79
+ if (value === undefined)
80
+ return undefined;
81
+ if (isRecord(value))
82
+ return value;
83
+ issues.push({ field, message: `${field} must be an object; got ${shown(value)}.` });
84
+ return undefined;
85
+ }
86
+ function lightingIssues(render, issues) {
87
+ const lighting = block(render, 'render.lighting', issues);
88
+ const ambient = lighting && block(lighting, 'render.lighting.ambient', issues);
89
+ if (!ambient)
90
+ return;
91
+ colorIssue('render.lighting.ambient.color', ambient.color, issues);
92
+ if (ambient.intensity !== undefined)
93
+ rangeIssue('render.lighting.ambient.intensity', ambient.intensity, issues);
94
+ }
95
+ function postIssues(render, issues) {
96
+ const post = block(render, 'render.post', issues);
97
+ if (!post)
98
+ return;
99
+ const vignette = block(post, 'render.post.vignette', issues);
100
+ if (vignette) {
101
+ rangeIssue('render.post.vignette.intensity', vignette.intensity, issues);
102
+ rangeIssue('render.post.vignette.radius', vignette.radius, issues);
103
+ }
104
+ const grade = block(post, 'render.post.colorGrade', issues);
105
+ if (!grade)
106
+ return;
107
+ colorIssue('render.post.colorGrade.tint', grade.tint, issues);
108
+ if (grade.contrast !== undefined)
109
+ rangeIssue('render.post.colorGrade.contrast', grade.contrast, issues);
110
+ if (grade.saturation !== undefined)
111
+ rangeIssue('render.post.colorGrade.saturation', grade.saturation, issues);
112
+ }
113
+ /**
114
+ * Every field of a scene's `render.lighting` and `render.post` that is
115
+ * outside its range, each with a message naming the field and its range —
116
+ * what the MCP scene validation reports (CA-2). Other render fields are not
117
+ * checked here.
118
+ */
119
+ export function sceneRenderIssues(render) {
120
+ if (!isRecord(render))
121
+ return [];
122
+ const issues = [];
123
+ lightingIssues(render, issues);
124
+ postIssues(render, issues);
125
+ return issues;
126
+ }
package/dist/scene.d.ts CHANGED
@@ -28,8 +28,37 @@ export interface PrefabJson {
28
28
  type: 'character' | 'object' | 'tile';
29
29
  components: SceneComponentJson[];
30
30
  }
31
+ /**
32
+ * The scene's lighting (issue #78, ADR 0026). Declaring it — or spawning a
33
+ * Light — draws the scene lit; absent, the scene is drawn exactly as unlit.
34
+ */
35
+ export interface SceneLightingJson {
36
+ /** Where no light reaches. Absent fields: `#ffffff` and 1, the art as drawn. */
37
+ ambient?: {
38
+ color?: string;
39
+ intensity?: number;
40
+ };
41
+ }
42
+ /** The scene's Post Effects, each off unless declared (issue #78). */
43
+ export interface ScenePostJson {
44
+ /** Darkens the frame's edges: `intensity` 0..1 at the corners, starting at `radius` 0..1 from the centre. */
45
+ vignette?: {
46
+ intensity: number;
47
+ radius: number;
48
+ };
49
+ /** A `#rrggbb` tint multiplier, and contrast and saturation from 0 to 2 (1 leaves the frame unchanged). */
50
+ colorGrade?: {
51
+ tint?: string;
52
+ contrast?: number;
53
+ saturation?: number;
54
+ };
55
+ }
31
56
  /** Scene-wide render options (v3). */
32
57
  export interface SceneRenderJson {
58
+ /** Ambient Light; with it (or any Light) the scene is lit (ADR 0026). */
59
+ lighting?: SceneLightingJson;
60
+ /** Vignette and color grade; with either on, the frame renders through a render target. */
61
+ post?: ScenePostJson;
33
62
  /**
34
63
  * 'y' orders same-layer sprites by their render-space Y — lower Y renders
35
64
  * in front (top-down depth). Absent: spawn order breaks same-layer ties.
@@ -1,5 +1,6 @@
1
1
  import { attribute, context, mat3, positionLocal, uv, vec3, vertexStage } from 'three/tsl';
2
2
  import * as THREE from 'three/webgpu';
3
+ import { TRANSPARENT_TEXEL_ALPHA } from './render-layers.js';
3
4
  /** The first capacity of a batch's instance buffers; it doubles when full. */
4
5
  export const INITIAL_SPRITE_BATCH_CAPACITY = 16;
5
6
  /** The key's canonical string: equal keys share one batch. */
@@ -57,7 +58,7 @@ class SpriteBatchMaterial extends THREE.MeshBasicNodeMaterial {
57
58
  function createMaterial(key, textures) {
58
59
  // Double-sided: a flipped sprite mirrors through a negative x scale in its
59
60
  // matrix, exactly like its own mesh; per instance there is no winding flip.
60
- const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide });
61
+ const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide, alphaTest: TRANSPARENT_TEXEL_ALPHA });
61
62
  material.forceSinglePass = true;
62
63
  if (key.texture === null)
63
64
  return { material, settled: null };
@@ -100,6 +101,9 @@ export class SpriteRunMesh {
100
101
  this.mesh.instanceColor = this.colors;
101
102
  // Drawn in an explicit renderOrder; its instances are already in view space.
102
103
  this.mesh.frustumCulled = false;
104
+ // A run is collected through its sprites' anchors, which carry their own
105
+ // layers (Emissive or not, ADR 0026): the run itself is on every layer.
106
+ this.mesh.layers.enableAll();
103
107
  this.mesh.matrixAutoUpdate = false;
104
108
  this.mesh.boundingSphere = new THREE.Sphere();
105
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/engine",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Waica game engine core — archetype-driven, web-first, 2D & 3D",
5
5
  "license": "MIT",
6
6
  "type": "module",