Skip to content

Commit 13028f1

Browse files
authored
Merge pull request #198 from JollyPixel/engine-logger
feat(engine): world.debug mode and logger with namespaces
1 parent f4409f9 commit 13028f1

File tree

9 files changed

+469
-5
lines changed

9 files changed

+469
-5
lines changed

.changeset/strong-geckos-start.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/engine": minor
3+
---
4+
5+
Implement World debug mode and logger with namespaces
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
// Import Third-party Dependencies
2+
import pm from "picomatch";
3+
4+
// Import Internal Dependencies
5+
import type { ConsoleAdapter } from "../adapters/console.ts";
6+
7+
export type LogLevel = "void" | "trace" | "debug" | "info" | "warn" | "error" | "fatal";
8+
9+
// CONSTANTS
10+
export const kLogLevelValue: Record<LogLevel, number> = {
11+
void: 0,
12+
trace: 10,
13+
debug: 20,
14+
info: 30,
15+
warn: 40,
16+
error: 50,
17+
fatal: 60
18+
};
19+
20+
export interface LoggerOptions {
21+
/**
22+
* Minimum severity level to emit. Messages below this level are silently dropped.
23+
* Default: "info"
24+
*/
25+
level?: LogLevel;
26+
/**
27+
* Namespace glob patterns to enable. Uses dot-notation with wildcard support:
28+
* "systems.*" → matches systems, systems.sceneManager, systems.asset.queue, ...
29+
* "systems.asset.*" → matches systems.asset, systems.asset.registry, ...
30+
* "*" → matches all namespaces (equivalent to debug mode)
31+
*
32+
* Default: [] (nothing is logged)
33+
*/
34+
namespaces?: string[];
35+
/**
36+
* Output adapter. Defaults to the global `console`.
37+
*/
38+
adapter?: ConsoleAdapter;
39+
}
40+
41+
export interface LoggerChildOptions {
42+
namespace: string;
43+
}
44+
45+
// CONSTANTS
46+
const kPrivateToken = Symbol("LoggerPrivate");
47+
48+
interface LoggerPrivateOptions {
49+
token: typeof kPrivateToken;
50+
namespace: string;
51+
root: LoggerState;
52+
}
53+
54+
/**
55+
* Holds the shared mutable state of a logger tree.
56+
* All child loggers created via `.child()` reference the same LoggerState,
57+
* so runtime changes to level or namespaces propagate immediately.
58+
*/
59+
class LoggerState {
60+
level: LogLevel;
61+
namespaces: string[];
62+
adapter: ConsoleAdapter;
63+
64+
#matchers: Map<string, (str: string) => boolean> = new Map();
65+
66+
constructor(
67+
options: LoggerOptions
68+
) {
69+
const {
70+
level = "info",
71+
namespaces = [],
72+
adapter = console
73+
} = options;
74+
75+
this.level = level;
76+
this.namespaces = structuredClone(namespaces);
77+
this.adapter = adapter;
78+
}
79+
80+
invalidateMatchers(): void {
81+
this.#matchers.clear();
82+
}
83+
84+
isNamespaceEnabled(
85+
namespace: string
86+
): boolean {
87+
if (this.namespaces.length === 0) {
88+
return false;
89+
}
90+
91+
const normalized = normalizeNamespace(namespace);
92+
93+
for (const pattern of this.namespaces) {
94+
let matcher = this.#matchers.get(pattern);
95+
96+
if (!matcher) {
97+
matcher = pm(normalizePattern(pattern));
98+
this.#matchers.set(pattern, matcher);
99+
}
100+
101+
if (normalized === "" || matcher(normalized)) {
102+
return true;
103+
}
104+
}
105+
106+
return false;
107+
}
108+
}
109+
110+
export class Logger {
111+
readonly #root: LoggerState;
112+
readonly namespace: string;
113+
114+
constructor(options?: LoggerOptions);
115+
constructor(options: LoggerPrivateOptions);
116+
constructor(options: LoggerOptions | LoggerPrivateOptions = {}) {
117+
if ("token" in options && options.token === kPrivateToken) {
118+
this.namespace = options.namespace;
119+
this.#root = options.root;
120+
}
121+
else {
122+
this.namespace = "";
123+
this.#root = new LoggerState(options as LoggerOptions);
124+
}
125+
}
126+
127+
get level(): LogLevel {
128+
return this.#root.level;
129+
}
130+
131+
/**
132+
* Changes the minimum log level for the entire logger tree.
133+
* Affects all child loggers immediately since they share the same state.
134+
*/
135+
setLevel(
136+
level: LogLevel
137+
): void {
138+
this.#root.level = level;
139+
}
140+
141+
/**
142+
* Adds one or more namespace patterns to the enabled set.
143+
* Invalidates the matcher cache so new patterns take effect immediately.
144+
*
145+
* @example
146+
* logger.enableNamespace("systems.*", "actor");
147+
*/
148+
enableNamespace(
149+
...patterns: string[]
150+
): void {
151+
this.#root.namespaces.push(...patterns);
152+
this.#root.invalidateMatchers();
153+
}
154+
155+
/**
156+
* Removes one or more namespace patterns from the enabled set.
157+
*/
158+
disableNamespace(
159+
...patterns: string[]
160+
): void {
161+
for (const pattern of patterns) {
162+
const index = this.#root.namespaces.indexOf(pattern);
163+
164+
if (index !== -1) {
165+
this.#root.namespaces.splice(index, 1);
166+
}
167+
}
168+
this.#root.invalidateMatchers();
169+
}
170+
171+
/**
172+
* Returns true if the given level would be emitted given the current minimum level.
173+
*/
174+
isLevelEnabled(
175+
level: LogLevel
176+
): boolean {
177+
return kLogLevelValue[level] >= kLogLevelValue[this.#root.level];
178+
}
179+
180+
/**
181+
* Returns true if this logger's own namespace is currently enabled.
182+
*/
183+
isNamespaceEnabled(): boolean {
184+
return this.#root.isNamespaceEnabled(this.namespace);
185+
}
186+
187+
/**
188+
* Creates a child logger whose namespace is appended to this logger's namespace.
189+
* The child shares the same level, namespace list, and adapter as the root.
190+
*
191+
* @example
192+
* const root = new Logger({ level: "debug", namespaces: ["systems.*"] });
193+
* const child = root.child({ namespace: "systems.sceneManager" });
194+
* child.debug("setScene", { scene: "Game" });
195+
* // → [DEBUG] [systems.sceneManager] setScene
196+
*/
197+
child(
198+
options: LoggerChildOptions
199+
): Logger {
200+
const ns = this.namespace
201+
? `${this.namespace}.${options.namespace}`
202+
: options.namespace;
203+
204+
return new Logger({
205+
token: kPrivateToken,
206+
namespace: ns,
207+
root: this.#root
208+
});
209+
}
210+
211+
trace(
212+
msg: string,
213+
meta?: Record<string, unknown>
214+
): void {
215+
if (this.#root.level === "void") {
216+
return;
217+
}
218+
219+
this.#emit("trace", msg, meta);
220+
}
221+
222+
debug(
223+
msg: string,
224+
meta?: Record<string, unknown>
225+
): void {
226+
if (this.#root.level === "void") {
227+
return;
228+
}
229+
230+
this.#emit("debug", msg, meta);
231+
}
232+
233+
info(
234+
msg: string,
235+
meta?: Record<string, unknown>
236+
): void {
237+
if (this.#root.level === "void") {
238+
return;
239+
}
240+
241+
this.#emit("info", msg, meta);
242+
}
243+
244+
warn(
245+
msg: string,
246+
meta?: Record<string, unknown>
247+
): void {
248+
if (this.#root.level === "void") {
249+
return;
250+
}
251+
252+
this.#emit("warn", msg, meta);
253+
}
254+
255+
error(
256+
msg: string,
257+
meta?: Record<string, unknown>
258+
): void {
259+
if (this.#root.level === "void") {
260+
return;
261+
}
262+
263+
this.#emit("error", msg, meta);
264+
}
265+
266+
fatal(
267+
msg: string,
268+
meta?: Record<string, unknown>
269+
): void {
270+
if (this.#root.level === "void") {
271+
return;
272+
}
273+
274+
this.#emit("fatal", msg, meta);
275+
}
276+
277+
#emit(
278+
level: LogLevel,
279+
msg: string,
280+
meta: Record<string, unknown> | undefined
281+
): void {
282+
if (this.#root.level === "void") {
283+
return;
284+
}
285+
286+
if (
287+
!this.isLevelEnabled(level) ||
288+
!this.isNamespaceEnabled()
289+
) {
290+
return;
291+
}
292+
293+
const ns = this.namespace || "root";
294+
const formatted = `[${level.toUpperCase()}] [${ns}] ${msg}`;
295+
296+
if (level === "warn") {
297+
if (meta === undefined) {
298+
this.#root.adapter.warn(formatted);
299+
}
300+
else {
301+
this.#root.adapter.warn(formatted, meta);
302+
}
303+
}
304+
else if (level === "error" || level === "fatal") {
305+
if (meta === undefined) {
306+
this.#root.adapter.error(formatted);
307+
}
308+
else {
309+
this.#root.adapter.error(formatted, meta);
310+
}
311+
}
312+
else if (meta === undefined) {
313+
this.#root.adapter.log(formatted);
314+
}
315+
else {
316+
this.#root.adapter.log(formatted, meta);
317+
}
318+
}
319+
}
320+
321+
/**
322+
* Converts a dot-notation namespace pattern into a slash-separated glob
323+
* compatible with picomatch.
324+
*
325+
* @example
326+
* "systems.*" → "systems/**"
327+
* "systems.asset.*" → "systems/asset/**"
328+
* "*" → "**"
329+
* "systems" → "systems"
330+
*/
331+
function normalizePattern(
332+
pattern: string
333+
): string {
334+
const normalized = pattern.replaceAll(".", "/");
335+
336+
if (normalized === "*") {
337+
return "**";
338+
}
339+
340+
if (normalized.endsWith("/*")) {
341+
return `${normalized.slice(0, -1)}**`;
342+
}
343+
344+
return normalized;
345+
}
346+
347+
function normalizeNamespace(
348+
namespace: string
349+
): string {
350+
return namespace.replaceAll(".", "/");
351+
}

packages/engine/src/systems/Scene.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EventEmitter } from "@posva/event-emitter";
55
// Import Internal Dependencies
66
import type { World, WorldDefaultContext } from "./World.ts";
77
import { IntegerIncrement } from "./generators/IntegerIncrement.ts";
8+
import type { Logger } from "./Logger.ts";
89

910
export type SceneLifecycleEvents = {
1011
awake: [];
@@ -23,12 +24,26 @@ export abstract class Scene<
2324
/** Set by SceneManager when the scene is activated. */
2425
world!: World<any, TContext>;
2526

27+
#logger: Logger | undefined;
28+
2629
constructor(name: string) {
2730
super();
2831
this.id = Scene.Id.incr();
2932
this.name = name;
3033
}
3134

35+
/**
36+
* A child logger scoped to this scene's namespace (`scenes.<name>`).
37+
* Created lazily on first access; safe to use from `awake()` onwards.
38+
*/
39+
get logger(): Logger {
40+
this.#logger ??= this.world.logger.child({
41+
namespace: `Scene.${this.name}`
42+
});
43+
44+
return this.#logger;
45+
}
46+
3247
/**
3348
* Called once when the scene is first activated (before the first start/update).
3449
* Populate actors here.

0 commit comments

Comments
 (0)