aether-engine 1.0.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 (73) hide show
  1. package/README.md +15 -0
  2. package/biome.json +51 -0
  3. package/bun.lock +192 -0
  4. package/index.ts +1 -0
  5. package/package.json +25 -0
  6. package/serve.ts +125 -0
  7. package/src/audio/AudioEngine.ts +61 -0
  8. package/src/components/Animator3D.ts +65 -0
  9. package/src/components/AudioSource.ts +26 -0
  10. package/src/components/BitmapText.ts +25 -0
  11. package/src/components/Camera.ts +33 -0
  12. package/src/components/CameraFollow.ts +5 -0
  13. package/src/components/Collider.ts +16 -0
  14. package/src/components/Components.test.ts +68 -0
  15. package/src/components/Light.ts +15 -0
  16. package/src/components/MeshRenderer.ts +58 -0
  17. package/src/components/ParticleEmitter.ts +59 -0
  18. package/src/components/RigidBody.ts +9 -0
  19. package/src/components/ShadowCaster.ts +3 -0
  20. package/src/components/SkinnedMeshRenderer.ts +25 -0
  21. package/src/components/SpriteAnimator.ts +42 -0
  22. package/src/components/SpriteRenderer.ts +26 -0
  23. package/src/components/Transform.test.ts +39 -0
  24. package/src/components/Transform.ts +54 -0
  25. package/src/core/AssetManager.ts +123 -0
  26. package/src/core/Input.test.ts +67 -0
  27. package/src/core/Input.ts +94 -0
  28. package/src/core/Scene.ts +24 -0
  29. package/src/core/SceneManager.ts +57 -0
  30. package/src/core/Storage.ts +161 -0
  31. package/src/desktop/SteamClient.ts +52 -0
  32. package/src/ecs/System.ts +11 -0
  33. package/src/ecs/World.test.ts +29 -0
  34. package/src/ecs/World.ts +149 -0
  35. package/src/index.ts +115 -0
  36. package/src/math/Color.ts +100 -0
  37. package/src/math/Vector2.ts +96 -0
  38. package/src/math/Vector3.ts +103 -0
  39. package/src/math/math.test.ts +168 -0
  40. package/src/renderer/GlowMaterial.ts +66 -0
  41. package/src/renderer/LitMaterial.ts +337 -0
  42. package/src/renderer/Material.test.ts +23 -0
  43. package/src/renderer/Material.ts +80 -0
  44. package/src/renderer/OcclusionMaterial.ts +43 -0
  45. package/src/renderer/ParticleMaterial.ts +66 -0
  46. package/src/renderer/Shader.ts +44 -0
  47. package/src/renderer/SkinnedLitMaterial.ts +55 -0
  48. package/src/renderer/WaterMaterial.ts +298 -0
  49. package/src/renderer/WebGLRenderer.ts +917 -0
  50. package/src/systems/Animation3DSystem.ts +148 -0
  51. package/src/systems/AnimationSystem.ts +58 -0
  52. package/src/systems/AudioSystem.ts +62 -0
  53. package/src/systems/LightingSystem.ts +114 -0
  54. package/src/systems/ParticleSystem.ts +278 -0
  55. package/src/systems/PhysicsSystem.ts +211 -0
  56. package/src/systems/Systems.test.ts +165 -0
  57. package/src/systems/TextSystem.ts +153 -0
  58. package/src/ui/AnimationEditor.tsx +639 -0
  59. package/src/ui/BottomPanel.tsx +443 -0
  60. package/src/ui/EntityExplorer.tsx +420 -0
  61. package/src/ui/GameState.ts +286 -0
  62. package/src/ui/Icons.tsx +239 -0
  63. package/src/ui/InventoryPanel.tsx +335 -0
  64. package/src/ui/PlayerHUD.tsx +250 -0
  65. package/src/ui/SpriteEditor.tsx +3241 -0
  66. package/src/ui/SpriteSheetManager.tsx +198 -0
  67. package/src/utils/GLTFLoader.ts +257 -0
  68. package/src/utils/ObjLoader.ts +81 -0
  69. package/src/utils/idb.ts +137 -0
  70. package/src/utils/packer.ts +85 -0
  71. package/test_obj.ts +12 -0
  72. package/tsconfig.json +21 -0
  73. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,420 @@
1
+ import {
2
+ createMemo,
3
+ createSignal,
4
+ For,
5
+ onCleanup,
6
+ onMount,
7
+ Show,
8
+ } from "solid-js";
9
+ import type { ComponentClass, Entity } from "../ecs/World";
10
+ import { type AetherEngine, Transform } from "../index";
11
+ import { IconChevronDown, IconChevronRight } from "./Icons";
12
+
13
+ const DataRow = (props: { label: string; value: any }) => {
14
+ const formatValue = (v: any, depth = 0): string => {
15
+ if (depth > 2) {
16
+ if (typeof v === "object" && v !== null)
17
+ return v.constructor?.name || "{...}";
18
+ return String(v);
19
+ }
20
+ if (typeof v === "number") {
21
+ return Number.isInteger(v) ? v.toString() : v.toFixed(3);
22
+ }
23
+ if (typeof v === "boolean") return v ? "true" : "false";
24
+ if (typeof v === "string") return `"${v}"`;
25
+
26
+ if (v instanceof Float32Array || Array.isArray(v)) {
27
+ const arr = Array.from(v);
28
+ const limit = 16;
29
+ const peek = arr
30
+ .slice(0, limit)
31
+ .map((n: any) => formatValue(n, depth + 1))
32
+ .join(", ");
33
+ if (arr.length > limit) return `[${peek}, ... (${arr.length} items)]`;
34
+ return `[${peek}]`;
35
+ }
36
+
37
+ if (v === null) return "null";
38
+ if (v === undefined) return "undefined";
39
+
40
+ if (v instanceof Set) {
41
+ const arr = Array.from(v);
42
+ const limit = 8;
43
+ const peek = arr
44
+ .slice(0, limit)
45
+ .map((n) => formatValue(n, depth + 1))
46
+ .join(", ");
47
+ return `Set(${v.size}) { ${peek}${v.size > limit ? ", ..." : ""} }`;
48
+ }
49
+
50
+ if (v instanceof Map) {
51
+ const arr = Array.from(v.entries());
52
+ const limit = 8;
53
+ const peek = arr
54
+ .slice(0, limit)
55
+ .map(([k, val]) => `${k} => ${formatValue(val, depth + 1)}`)
56
+ .join(", ");
57
+ return `Map(${v.size}) { ${peek}${v.size > limit ? ", ..." : ""} }`;
58
+ }
59
+
60
+ if (typeof v === "object") {
61
+ if (v.constructor && v.constructor.name !== "Object") {
62
+ return `${v.constructor.name} {...}`;
63
+ }
64
+ try {
65
+ const keys = Object.keys(v);
66
+ const limit = 8;
67
+ const peek = keys
68
+ .slice(0, limit)
69
+ .map((k) => `${k}: ${formatValue(v[k], depth + 1)}`)
70
+ .join(", ");
71
+ return `{ ${peek}${keys.length > limit ? ", ..." : ""} }`;
72
+ } catch (_e) {
73
+ return "{...}";
74
+ }
75
+ }
76
+ if (typeof v === "function") return `ƒ ${v.name || "anonymous"}()`;
77
+ return String(v);
78
+ };
79
+
80
+ return (
81
+ <div
82
+ style={{
83
+ display: "flex",
84
+ "justify-content": "space-between",
85
+ "font-family": "monospace",
86
+ "font-size": "11px",
87
+ padding: "3px 0",
88
+ "border-bottom": "1px solid #2d2d2d",
89
+ }}
90
+ >
91
+ <div style={{ color: "#9cdcfe" }}>{props.label}</div>
92
+ <div
93
+ style={{
94
+ color: "#ce9178",
95
+ "text-align": "right",
96
+ "white-space": "nowrap",
97
+ overflow: "hidden",
98
+ "text-overflow": "ellipsis",
99
+ "max-width": "80%",
100
+ }}
101
+ title={formatValue(props.value)}
102
+ >
103
+ {formatValue(props.value)}
104
+ </div>
105
+ </div>
106
+ );
107
+ };
108
+
109
+ const ComponentInspector = (props: {
110
+ entity: Entity;
111
+ engine: AetherEngine;
112
+ cls: ComponentClass;
113
+ tick: number;
114
+ }) => {
115
+ const [expanded, setExpanded] = createSignal(false);
116
+
117
+ const entries = createMemo(() => {
118
+ props.tick; // Force dependency
119
+ const comp = props.engine.world.getComponent(props.entity, props.cls);
120
+ if (!comp) return [];
121
+ return Object.entries(comp).filter(
122
+ ([key]) => key !== "parent" && key !== "children",
123
+ );
124
+ });
125
+
126
+ return (
127
+ <div
128
+ style={{
129
+ "margin-left": "22px",
130
+ "margin-top": "4px",
131
+ "margin-bottom": "4px",
132
+ background: "#1e1e1e",
133
+ border: "1px solid #333",
134
+ "border-radius": "3px",
135
+ }}
136
+ >
137
+ <div
138
+ style={{
139
+ padding: "4px 6px",
140
+ cursor: "pointer",
141
+ display: "flex",
142
+ "align-items": "center",
143
+ background: "#252526",
144
+ }}
145
+ onClick={() => setExpanded(!expanded())}
146
+ >
147
+ <div
148
+ style={{
149
+ "margin-right": "6px",
150
+ display: "flex",
151
+ "align-items": "center",
152
+ }}
153
+ >
154
+ {expanded() ? (
155
+ <IconChevronDown size={12} />
156
+ ) : (
157
+ <IconChevronRight size={12} />
158
+ )}
159
+ </div>
160
+ <span
161
+ style={{
162
+ "font-weight": "bold",
163
+ color: "#4ec9b0",
164
+ "font-size": "12px",
165
+ }}
166
+ >
167
+ {props.cls.name}
168
+ </span>
169
+ </div>
170
+ <Show when={expanded()}>
171
+ <div style={{ padding: "4px 8px" }}>
172
+ <For each={entries()}>
173
+ {([k, v]) => <DataRow label={k} value={v} />}
174
+ </For>
175
+ </div>
176
+ </Show>
177
+ </div>
178
+ );
179
+ };
180
+
181
+ const EntityNode = (props: {
182
+ entity: Entity;
183
+ depth: number;
184
+ engine: AetherEngine;
185
+ tMap: () => Map<Transform, Entity>;
186
+ tick: number;
187
+ }) => {
188
+ const [expanded, setExpanded] = createSignal(false);
189
+
190
+ const componentClasses = createMemo(() => {
191
+ props.tick;
192
+ const map = props.engine.world.getComponentsForEntity(props.entity);
193
+ if (!map) return [];
194
+ return Array.from(map.keys());
195
+ });
196
+
197
+ const childEntities = createMemo(() => {
198
+ props.tick;
199
+ const t = props.engine.world.getComponent(props.entity, Transform);
200
+ if (!t) return [];
201
+ return Array.from(t.children)
202
+ .map((childT) => props.tMap().get(childT))
203
+ .filter((id) => id !== undefined) as Entity[];
204
+ });
205
+
206
+ const hasChildren = createMemo(() => childEntities().length > 0);
207
+
208
+ return (
209
+ <div style={{ "margin-left": `${props.depth * 14}px` }}>
210
+ <div
211
+ style={{
212
+ display: "flex",
213
+ "align-items": "center",
214
+ padding: "5px",
215
+ cursor: "pointer",
216
+ "border-radius": "3px",
217
+ "user-select": "none",
218
+ }}
219
+ onMouseOver={(e) => (e.currentTarget.style.background = "#2a2d2e")}
220
+ onMouseOut={(e) => (e.currentTarget.style.background = "transparent")}
221
+ onClick={() => setExpanded(!expanded())}
222
+ >
223
+ <div
224
+ style={{
225
+ display: "flex",
226
+ "align-items": "center",
227
+ width: "16px",
228
+ "justify-content": "center",
229
+ }}
230
+ >
231
+ <Show when={hasChildren()}>
232
+ {expanded() ? (
233
+ <IconChevronDown size={14} />
234
+ ) : (
235
+ <IconChevronRight size={14} />
236
+ )}
237
+ </Show>
238
+ </div>
239
+ <div
240
+ style={{
241
+ "margin-left": "4px",
242
+ color: "#d4d4d4",
243
+ "font-weight": "bold",
244
+ "font-size": "13px",
245
+ }}
246
+ >
247
+ Entity #{props.entity}
248
+ </div>
249
+ <div
250
+ style={{
251
+ "margin-left": "8px",
252
+ color: "#888",
253
+ "font-size": "11px",
254
+ "pointer-events": "none",
255
+ }}
256
+ >
257
+ {componentClasses().length} components
258
+ </div>
259
+ </div>
260
+
261
+ <Show when={expanded()}>
262
+ <div
263
+ style={{
264
+ "margin-bottom": "8px",
265
+ "padding-bottom": "4px",
266
+ "border-bottom": "1px dashed #333",
267
+ }}
268
+ >
269
+ <For each={componentClasses()}>
270
+ {(cls) => (
271
+ <ComponentInspector
272
+ entity={props.entity}
273
+ engine={props.engine}
274
+ cls={cls}
275
+ tick={props.tick}
276
+ />
277
+ )}
278
+ </For>
279
+
280
+ <div style={{ "margin-top": "6px" }}>
281
+ <For each={childEntities()}>
282
+ {(childE) => (
283
+ <EntityNode
284
+ entity={childE}
285
+ depth={props.depth + 1}
286
+ engine={props.engine}
287
+ tMap={props.tMap}
288
+ tick={props.tick}
289
+ />
290
+ )}
291
+ </For>
292
+ </div>
293
+ </div>
294
+ </Show>
295
+ </div>
296
+ );
297
+ };
298
+
299
+ export const EntityExplorer = (props: { engine: AetherEngine }) => {
300
+ const [tick, setTick] = createSignal(0);
301
+ const [roots, setRoots] = createSignal<Entity[]>([]);
302
+ const [tMap, setTMap] = createSignal<Map<Transform, Entity>>(new Map());
303
+ const [search, setSearch] = createSignal("");
304
+
305
+ onMount(() => {
306
+ let timer: number;
307
+ const loop = () => {
308
+ const all = props.engine.world.getAllEntities();
309
+ const newTMap = new Map<Transform, Entity>();
310
+ const newRoots: Entity[] = [];
311
+
312
+ for (const e of all) {
313
+ const t = props.engine.world.getComponent(e, Transform);
314
+ if (t) {
315
+ newTMap.set(t, e);
316
+ }
317
+ }
318
+
319
+ for (const e of all) {
320
+ const t = props.engine.world.getComponent(e, Transform);
321
+ if (!t || t.parent === null) {
322
+ newRoots.push(e);
323
+ }
324
+ }
325
+
326
+ setTMap(newTMap);
327
+ setRoots(newRoots);
328
+ setTick((t) => t + 1);
329
+
330
+ timer = window.setTimeout(loop, 250);
331
+ };
332
+ loop();
333
+ onCleanup(() => window.clearTimeout(timer));
334
+ });
335
+
336
+ const themeBorder = "#222222";
337
+
338
+ const displayRoots = createMemo(() => {
339
+ const s = search().toLowerCase();
340
+ if (!s) return roots();
341
+
342
+ // Flat search bypasses hierarchy logic if search is active
343
+ const all = props.engine.world.getAllEntities();
344
+ return all.filter((e) => {
345
+ if (e.toString().includes(s)) return true;
346
+ const map = props.engine.world.getComponentsForEntity(e);
347
+ if (!map) return false;
348
+ for (const cls of map.keys()) {
349
+ if (cls.name.toLowerCase().includes(s)) return true;
350
+ }
351
+ return false;
352
+ });
353
+ });
354
+
355
+ return (
356
+ <div
357
+ style={{
358
+ display: "flex",
359
+ "flex-direction": "column",
360
+ height: "100%",
361
+ background: "#1e1e1e",
362
+ "border-radius": "4px",
363
+ border: `1px solid ${themeBorder}`,
364
+ overflow: "hidden",
365
+ }}
366
+ >
367
+ <div
368
+ style={{
369
+ padding: "8px",
370
+ background: "#252526",
371
+ "border-bottom": `1px solid ${themeBorder}`,
372
+ }}
373
+ >
374
+ <input
375
+ type="text"
376
+ placeholder="Filter by ID or Component (e.g., 'SpriteRenderer')"
377
+ value={search()}
378
+ onInput={(e) => setSearch(e.currentTarget.value)}
379
+ onKeyDown={(e) => e.stopPropagation()}
380
+ onKeyUp={(e) => e.stopPropagation()}
381
+ style={{
382
+ width: "100%",
383
+ padding: "6px 8px",
384
+ background: "#3c3c3c",
385
+ border: "1px solid transparent",
386
+ color: "#cccccc",
387
+ "border-radius": "3px",
388
+ outline: "none",
389
+ "font-size": "12px",
390
+ "box-sizing": "border-box",
391
+ }}
392
+ onFocus={(e) => (e.currentTarget.style.border = "1px solid #007fd4")}
393
+ onBlur={(e) =>
394
+ (e.currentTarget.style.border = "1px solid transparent")
395
+ }
396
+ />
397
+ </div>
398
+ <div
399
+ style={{
400
+ flex: 1,
401
+ "overflow-y": "auto",
402
+ padding: "8px",
403
+ background: "#1e1e1e",
404
+ }}
405
+ >
406
+ <For each={displayRoots()}>
407
+ {(entity) => (
408
+ <EntityNode
409
+ entity={entity}
410
+ depth={0}
411
+ engine={props.engine}
412
+ tMap={tMap}
413
+ tick={tick()}
414
+ />
415
+ )}
416
+ </For>
417
+ </div>
418
+ </div>
419
+ );
420
+ };
@@ -0,0 +1,286 @@
1
+ import { createSignal } from "solid-js";
2
+
3
+ export interface Item {
4
+ id: string;
5
+ name: string;
6
+ icon: string;
7
+ iconX?: number; // sprite offset X in px
8
+ iconY?: number; // sprite offset Y in px
9
+ iconW?: number; // sprite width locally
10
+ iconH?: number; // sprite height locally
11
+ atlasW?: number; // full atlas width
12
+ atlasH?: number; // full atlas height
13
+ quantity: number;
14
+ }
15
+
16
+ export type InventorySource = "player" | "chest" | "hotbar";
17
+
18
+ export const [playerInv, setPlayerInv] = createSignal<(Item | null)[]>(
19
+ Array(16).fill(null),
20
+ );
21
+ export const [chestInv, setChestInv] = createSignal<(Item | null)[]>(
22
+ Array(16).fill(null),
23
+ );
24
+ export const [hotbarSlots, setHotbarSlots] = createSignal<(Item | null)[]>(
25
+ Array(5).fill(null),
26
+ );
27
+
28
+ export const [selectedHotbarIndex, setSelectedHotbarIndex] =
29
+ createSignal<number>(0);
30
+ export const [draggedItem, setDraggedItem] = createSignal<{
31
+ source: InventorySource;
32
+ index: number;
33
+ } | null>(null);
34
+ export const [dragHoverTarget, setDragHoverTarget] = createSignal<{
35
+ source: InventorySource;
36
+ index: number;
37
+ } | null>(null);
38
+ export const [isDropHandled, setIsDropHandled] = createSignal<boolean>(false);
39
+
40
+ export const getInvAccessor = (source: InventorySource) => {
41
+ if (source === "player") return playerInv;
42
+ if (source === "chest") return chestInv;
43
+ return hotbarSlots;
44
+ };
45
+
46
+ export const setInvAccessor = (
47
+ source: InventorySource,
48
+ newInv: (Item | null)[],
49
+ ) => {
50
+ if (source === "player") setPlayerInv(newInv);
51
+ else if (source === "chest") setChestInv(newInv);
52
+ else setHotbarSlots(newInv);
53
+ };
54
+
55
+ export const handleDrop = (
56
+ targetSource: InventorySource,
57
+ targetIndex: number,
58
+ ) => {
59
+ setIsDropHandled(true);
60
+ setDragHoverTarget(null);
61
+ const dragged = draggedItem();
62
+ if (!dragged) return;
63
+
64
+ if (dragged.source === targetSource && dragged.index === targetIndex) {
65
+ setDraggedItem(null);
66
+ return;
67
+ }
68
+
69
+ const sourceInv = getInvAccessor(dragged.source)();
70
+ const destInv = getInvAccessor(targetSource)();
71
+
72
+ const sItem = sourceInv[dragged.index];
73
+ const dItem = destInv[targetIndex];
74
+
75
+ const newSource = [...sourceInv];
76
+ const newDest = dragged.source === targetSource ? newSource : [...destInv];
77
+
78
+ // Stack
79
+ if (sItem && dItem && sItem.name === dItem.name) {
80
+ newDest[targetIndex] = {
81
+ ...dItem,
82
+ quantity: dItem.quantity + sItem.quantity,
83
+ };
84
+ newSource[dragged.index] = null;
85
+ } else {
86
+ // Swap
87
+ newDest[targetIndex] = sItem;
88
+ newSource[dragged.index] = dItem;
89
+ }
90
+
91
+ setInvAccessor(targetSource, newDest);
92
+ if (dragged.source !== targetSource) {
93
+ setInvAccessor(dragged.source, newSource);
94
+ }
95
+ setDraggedItem(null);
96
+
97
+ // Notify ECS of equipment change if hotbar changed
98
+ notifyEquipmentChange();
99
+ };
100
+
101
+ export const setHotbarIndex = (index: number) => {
102
+ setSelectedHotbarIndex(index);
103
+ notifyEquipmentChange();
104
+ };
105
+
106
+ export const notifyEquipmentChange = () => {
107
+ const currentHotbarItem = hotbarSlots()[selectedHotbarIndex()];
108
+ window.dispatchEvent(
109
+ new CustomEvent("EquippedItemChanged", {
110
+ detail: { item: currentHotbarItem },
111
+ }),
112
+ );
113
+ };
114
+
115
+ export const initInventory = () => {
116
+ const pInv = Array(16).fill(null);
117
+ pInv[0] = {
118
+ id: "1",
119
+ name: "Wood",
120
+ icon: "/public/assets/sprites/Outdoor_Decoration/Oak_Tree.png",
121
+ quantity: 5,
122
+ };
123
+
124
+ // Atlas seeds 16x16, the atlas is 112x192
125
+ // Carrot seed bag: Col 4, Row 0 = X:64, Y:0
126
+ pInv[1] = {
127
+ id: "carrot_seed",
128
+ name: "Carrot Seed",
129
+ icon: "/public/assets/sprites/Outdoor_Decoration/Outdoor_Decor_Free.png",
130
+ iconX: 64,
131
+ iconY: 0,
132
+ iconW: 16,
133
+ iconH: 16,
134
+ atlasW: 112,
135
+ atlasH: 192,
136
+ quantity: 10,
137
+ };
138
+
139
+ // Wheat seed bag: Col 6, Row 0 = X:96, Y:0
140
+ pInv[2] = {
141
+ id: "wheat_seed",
142
+ name: "Wheat Seed",
143
+ icon: "/public/assets/sprites/Outdoor_Decoration/Outdoor_Decor_Free.png",
144
+ iconX: 96,
145
+ iconY: 0,
146
+ iconW: 16,
147
+ iconH: 16,
148
+ atlasW: 112,
149
+ atlasH: 192,
150
+ quantity: 15,
151
+ };
152
+ setPlayerInv(pInv);
153
+
154
+ const cInv = Array(16).fill(null);
155
+ cInv[0] = {
156
+ id: "3",
157
+ name: "Wood",
158
+ icon: "/public/assets/sprites/Outdoor_Decoration/Oak_Tree.png",
159
+ quantity: 10,
160
+ };
161
+ cInv[3] = {
162
+ id: "4",
163
+ name: "Chest",
164
+ icon: "/public/assets/sprites/Outdoor_Decoration/Chest.png",
165
+ quantity: 1,
166
+ };
167
+ setChestInv(cInv);
168
+
169
+ const hSlots = Array(5).fill(null);
170
+ setHotbarSlots(hSlots);
171
+ };
172
+
173
+ // Global Listener for ECS integration
174
+ if (typeof window !== "undefined") {
175
+ const addItemToInv = (itemObj: Item) => {
176
+ const currentInv = [...playerInv()];
177
+ let found = false;
178
+
179
+ // Stack existing
180
+ for (let i = 0; i < currentInv.length; i++) {
181
+ if (currentInv[i] && currentInv[i]?.id === itemObj.id) {
182
+ currentInv[i] = {
183
+ ...currentInv[i]!,
184
+ quantity: currentInv[i]?.quantity + itemObj.quantity,
185
+ };
186
+ found = true;
187
+ break;
188
+ }
189
+ }
190
+
191
+ // Find empty slot
192
+ if (!found) {
193
+ for (let i = 0; i < currentInv.length; i++) {
194
+ if (!currentInv[i]) {
195
+ currentInv[i] = { ...itemObj };
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ setPlayerInv(currentInv);
201
+ };
202
+
203
+ window.addEventListener("HarvestCrop", ((e: CustomEvent) => {
204
+ const type = e.detail?.type;
205
+ if (!type) return;
206
+
207
+ const itemObj: Item =
208
+ type === "carrot"
209
+ ? {
210
+ id: "carrot_item",
211
+ name: "Carrot",
212
+ icon: "/public/assets/sprites/Outdoor_Decoration/Outdoor_Decor_Free.png",
213
+ iconX: 64,
214
+ iconY: 48,
215
+ iconW: 16,
216
+ iconH: 16,
217
+ atlasW: 112,
218
+ atlasH: 192,
219
+ quantity: 1,
220
+ }
221
+ : {
222
+ id: "wheat_item",
223
+ name: "Wheat",
224
+ icon: "/public/assets/sprites/Outdoor_Decoration/Outdoor_Decor_Free.png",
225
+ iconX: 96,
226
+ iconY: 48,
227
+ iconW: 16,
228
+ iconH: 16,
229
+ atlasW: 112,
230
+ atlasH: 192,
231
+ quantity: 1,
232
+ };
233
+
234
+ addItemToInv(itemObj);
235
+ }) as EventListener);
236
+
237
+ window.addEventListener("PickUpItem", ((e: CustomEvent) => {
238
+ const itemObj = e.detail?.item;
239
+ if (itemObj) {
240
+ addItemToInv(itemObj);
241
+ }
242
+ }) as EventListener);
243
+
244
+ window.addEventListener("DeductItem", ((e: CustomEvent) => {
245
+ const typeId = e.detail?.id;
246
+ if (!typeId) return;
247
+
248
+ // Deduct from hotbar first, then player inv
249
+ let deducted = false;
250
+ const currentHotbar = [...hotbarSlots()];
251
+ for (let i = 0; i < currentHotbar.length; i++) {
252
+ if (currentHotbar[i] && currentHotbar[i]?.id === typeId) {
253
+ if (currentHotbar[i]?.quantity > 1) {
254
+ currentHotbar[i] = {
255
+ ...currentHotbar[i]!,
256
+ quantity: currentHotbar[i]?.quantity - 1,
257
+ };
258
+ } else {
259
+ currentHotbar[i] = null;
260
+ }
261
+ setHotbarSlots(currentHotbar);
262
+ deducted = true;
263
+ notifyEquipmentChange();
264
+ break;
265
+ }
266
+ }
267
+
268
+ if (!deducted) {
269
+ const currentInv = [...playerInv()];
270
+ for (let i = 0; i < currentInv.length; i++) {
271
+ if (currentInv[i] && currentInv[i]?.id === typeId) {
272
+ if (currentInv[i]?.quantity > 1) {
273
+ currentInv[i] = {
274
+ ...currentInv[i]!,
275
+ quantity: currentInv[i]?.quantity - 1,
276
+ };
277
+ } else {
278
+ currentInv[i] = null;
279
+ }
280
+ setPlayerInv(currentInv);
281
+ break;
282
+ }
283
+ }
284
+ }
285
+ }) as EventListener);
286
+ }