action-engine-js 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 (93) hide show
  1. package/LICENSE +45 -0
  2. package/README.md +348 -0
  3. package/actionengine/3rdparty/goblin/goblin.js +9609 -0
  4. package/actionengine/3rdparty/goblin/goblin.min.js +5 -0
  5. package/actionengine/camera/actioncamera.js +90 -0
  6. package/actionengine/camera/cameracollisionhandler.js +69 -0
  7. package/actionengine/character/actioncharacter.js +360 -0
  8. package/actionengine/character/actioncharacter3D.js +61 -0
  9. package/actionengine/core/app.js +430 -0
  10. package/actionengine/debug/basedebugpanel.js +858 -0
  11. package/actionengine/display/canvasmanager.js +75 -0
  12. package/actionengine/display/gl/programmanager.js +570 -0
  13. package/actionengine/display/gl/shaders/lineshader.js +118 -0
  14. package/actionengine/display/gl/shaders/objectshader.js +1756 -0
  15. package/actionengine/display/gl/shaders/particleshader.js +43 -0
  16. package/actionengine/display/gl/shaders/shadowshader.js +319 -0
  17. package/actionengine/display/gl/shaders/spriteshader.js +100 -0
  18. package/actionengine/display/gl/shaders/watershader.js +67 -0
  19. package/actionengine/display/graphics/actionmodel3D.js +191 -0
  20. package/actionengine/display/graphics/actionsprite3D.js +230 -0
  21. package/actionengine/display/graphics/lighting/actiondirectionalshadowlight.js +864 -0
  22. package/actionengine/display/graphics/lighting/actionlight.js +211 -0
  23. package/actionengine/display/graphics/lighting/actionomnidirectionalshadowlight.js +862 -0
  24. package/actionengine/display/graphics/lighting/lightingconstants.js +263 -0
  25. package/actionengine/display/graphics/lighting/lightmanager.js +789 -0
  26. package/actionengine/display/graphics/renderableobject.js +44 -0
  27. package/actionengine/display/graphics/renderers/actionrenderer2D.js +341 -0
  28. package/actionengine/display/graphics/renderers/actionrenderer3D/actionrenderer3D.js +655 -0
  29. package/actionengine/display/graphics/renderers/actionrenderer3D/canvasmanager3D.js +82 -0
  30. package/actionengine/display/graphics/renderers/actionrenderer3D/debugrenderer3D.js +493 -0
  31. package/actionengine/display/graphics/renderers/actionrenderer3D/objectrenderer3D.js +790 -0
  32. package/actionengine/display/graphics/renderers/actionrenderer3D/spriteRenderer3D.js +266 -0
  33. package/actionengine/display/graphics/renderers/actionrenderer3D/sunrenderer3D.js +140 -0
  34. package/actionengine/display/graphics/renderers/actionrenderer3D/waterrenderer3D.js +173 -0
  35. package/actionengine/display/graphics/renderers/actionrenderer3D/weatherrenderer3D.js +87 -0
  36. package/actionengine/display/graphics/texture/proceduraltexture.js +192 -0
  37. package/actionengine/display/graphics/texture/texturemanager.js +242 -0
  38. package/actionengine/display/graphics/texture/textureregistry.js +177 -0
  39. package/actionengine/input/actionscrollablearea.js +1405 -0
  40. package/actionengine/input/inputhandler.js +1647 -0
  41. package/actionengine/math/geometry/geometrybuilder.js +161 -0
  42. package/actionengine/math/geometry/glbexporter.js +364 -0
  43. package/actionengine/math/geometry/glbloader.js +722 -0
  44. package/actionengine/math/geometry/modelcodegenerator.js +97 -0
  45. package/actionengine/math/geometry/triangle.js +33 -0
  46. package/actionengine/math/geometry/triangleutils.js +34 -0
  47. package/actionengine/math/mathutils.js +25 -0
  48. package/actionengine/math/matrix4.js +785 -0
  49. package/actionengine/math/physics/actionphysics.js +108 -0
  50. package/actionengine/math/physics/actionphysicsobject3D.js +164 -0
  51. package/actionengine/math/physics/actionphysicsworld3D.js +238 -0
  52. package/actionengine/math/physics/actionraycast.js +129 -0
  53. package/actionengine/math/physics/shapes/actionphysicsbox3D.js +158 -0
  54. package/actionengine/math/physics/shapes/actionphysicscapsule3D.js +200 -0
  55. package/actionengine/math/physics/shapes/actionphysicscompoundshape3D.js +147 -0
  56. package/actionengine/math/physics/shapes/actionphysicscone3D.js +126 -0
  57. package/actionengine/math/physics/shapes/actionphysicsconvexshape3D.js +72 -0
  58. package/actionengine/math/physics/shapes/actionphysicscylinder3D.js +117 -0
  59. package/actionengine/math/physics/shapes/actionphysicsmesh3D.js +74 -0
  60. package/actionengine/math/physics/shapes/actionphysicsplane3D.js +100 -0
  61. package/actionengine/math/physics/shapes/actionphysicssphere3D.js +95 -0
  62. package/actionengine/math/quaternion.js +61 -0
  63. package/actionengine/math/vector2.js +277 -0
  64. package/actionengine/math/vector3.js +318 -0
  65. package/actionengine/math/viewfrustum.js +136 -0
  66. package/actionengine/network/ACTIONNETREADME.md +810 -0
  67. package/actionengine/network/client/ActionNetManager.js +802 -0
  68. package/actionengine/network/client/ActionNetManagerGUI.js +1709 -0
  69. package/actionengine/network/client/ActionNetManagerP2P.js +1537 -0
  70. package/actionengine/network/client/SyncSystem.js +422 -0
  71. package/actionengine/network/p2p/ActionNetPeer.js +142 -0
  72. package/actionengine/network/p2p/ActionNetTrackerClient.js +623 -0
  73. package/actionengine/network/p2p/DataConnection.js +282 -0
  74. package/actionengine/network/p2p/README.md +510 -0
  75. package/actionengine/network/p2p/example.html +502 -0
  76. package/actionengine/network/server/ActionNetServer.js +577 -0
  77. package/actionengine/network/server/ActionNetServerSSL.js +579 -0
  78. package/actionengine/network/server/ActionNetServerUtils.js +458 -0
  79. package/actionengine/network/server/SERVERREADME.md +314 -0
  80. package/actionengine/network/server/package-lock.json +35 -0
  81. package/actionengine/network/server/package.json +13 -0
  82. package/actionengine/network/server/start.bat +27 -0
  83. package/actionengine/network/server/start.sh +25 -0
  84. package/actionengine/network/server/startwss.bat +27 -0
  85. package/actionengine/sound/audiomanager.js +1589 -0
  86. package/actionengine/sound/soundfont/ACTIONSOUNDFONT_README.md +205 -0
  87. package/actionengine/sound/soundfont/actionparser.js +718 -0
  88. package/actionengine/sound/soundfont/actionreverb.js +252 -0
  89. package/actionengine/sound/soundfont/actionsoundfont.js +543 -0
  90. package/actionengine/sound/soundfont/sf2playerlicence.txt +29 -0
  91. package/actionengine/sound/soundfont/soundfont.js +2 -0
  92. package/dist/action-engine.min.js +328 -0
  93. package/package.json +35 -0
@@ -0,0 +1,1405 @@
1
+ /**
2
+ * ActionScrollableArea - A comprehensive, reusable scrollable area component for ActionEngine
3
+ *
4
+ * This class provides a complete scrollbar implementation that handles all common scrolling interactions
5
+ * including drag scrolling, click-to-jump, mouse wheel scrolling, and keyboard navigation. It's designed
6
+ * to be easily integrated into any UI that needs to display a scrollable list of items.
7
+ *
8
+ * KEY FEATURES:
9
+ * - Self-Contained: Draws both scrollbar and content area background automatically
10
+ * - Background Management: Handles content area background, borders, and styling independently
11
+ * - Drag & Drop: Click and drag the scrollbar thumb with threshold-based drag detection
12
+ * - Click-to-Jump: Click anywhere on the scrollbar track to instantly jump to that position
13
+ * - Mouse Wheel: Seamless scroll wheel support with proper delta handling
14
+ * - Keyboard Scrolling: Arrow key navigation when hovering over the list area
15
+ * - Proportional Display: Scrollbar thumb size and position accurately represent content ratio
16
+ * - Visual Feedback: Hover states, drag indicators, and smooth interactions
17
+ * - Customizable Background: Configurable content area background with borders and rounded corners
18
+ * - Easy Configuration: Simple configuration object for different use cases
19
+ *
20
+ * USAGE EXAMPLE:
21
+ * ```javascript
22
+ * // Basic setup - component handles everything automatically
23
+ * this.inventoryScroller = new ActionScrollableArea({
24
+ * listAreaX: 400, listAreaY: 100, listAreaWidth: 450, listAreaHeight: 400,
25
+ * itemHeight: 60, padding: 10, scrollBarX: 860, scrollBarY: 90,
26
+ * scrollBarTrackHeight: 380, scrollBarThumbStartY: 120
27
+ * }, game.input, game.guiCtx);
28
+ *
29
+ * // In your update loop
30
+ * this.inventoryScroller.update(this.game.gameState.inventory.length, deltaTime);
31
+ *
32
+ * // Enhanced draw method with automatic clipping and item rendering
33
+ * this.inventoryScroller.draw(items, (item, index, y) => {
34
+ * this.drawInventoryItem(item, y);
35
+ * });
36
+ * ```
37
+ *
38
+ * CONFIGURATION OPTIONS:
39
+ * - listAreaX, listAreaY, listAreaWidth, listAreaHeight: Define the scrollable content area
40
+ * - itemHeight: Pixel height of each item in your list
41
+ * - padding: Pixel gap between items in the list (default: 8)
42
+ * - scrollBarX, scrollBarY: Position of the scrollbar relative to content
43
+ * - scrollBarTrackHeight: Total height available for scrollbar track
44
+ * - scrollBarThumbStartY: Where the scrollbar thumb should start (usually = scrollBarY + 10)
45
+ *
46
+ * ADDITIONAL CONFIGURATION OPTIONS:
47
+ * - enableClipping: Enable clipping support (default: false)
48
+ * - clipBounds: {x, y, width, height} for clipping visible area
49
+ * - colors: Override default scrollbar colors (see colors config structure below)
50
+ * - backgroundColor: Background fill color for content area (default: "rgba(40, 40, 40, 0.8)")
51
+ * - borderColor: Border color for content area (default: "rgba(255, 255, 255, 0.3)")
52
+ * - borderWidth: Border thickness for content area (default: 2)
53
+ * - cornerRadius: Corner radius for rounded background (default: 0)
54
+ * - drawBackground: Whether to draw background automatically (default: true)
55
+ * - onRegisterInput: Custom input registration callback
56
+ * - onRegisterItemInput: Custom item input registration callback
57
+ * - generateItemId: Function to generate item IDs (item, index) => string
58
+ *
59
+ * IMPORTANT NOTES:
60
+ * - The component automatically registers its scrollbar elements with the input system
61
+ * - Mouse wheel events are captured automatically (no additional setup needed)
62
+ * - The scrollable area handles its own cleanup when destroy() is called
63
+ * - All positions are automatically calculated proportionally for smooth scrolling
64
+ * - Visual feedback (hover states, drag indicators) is handled internally
65
+ */
66
+
67
+ class ActionScrollableArea {
68
+ /**
69
+ * Creates a new scrollable area component with comprehensive scrolling functionality
70
+ *
71
+ * This constructor initializes all the necessary properties for scrollbar functionality,
72
+ * registers input handlers for user interactions, and sets up mouse wheel support.
73
+ * The component is immediately ready to use after construction.
74
+ *
75
+ * @param {Object} config - Configuration object defining the scrollable area's dimensions and behavior
76
+ * @param {number} config.listAreaX - X coordinate of the top-left corner of the scrollable content area
77
+ * @param {number} config.listAreaY - Y coordinate of the top-left corner of the scrollable content area
78
+ * @param {number} config.listAreaWidth - Width in pixels of the scrollable content area
79
+ * @param {number} config.listAreaHeight - Height in pixels of the scrollable content area
80
+ * @param {number} config.itemHeight - Height in pixels of each individual item in the list
81
+ * @param {number} [config.padding] - Pixel gap between items in the list (default: 8)
82
+ * @param {number} config.scrollBarX - X coordinate where the scrollbar should be positioned
83
+ * @param {number} config.scrollBarY - Y coordinate where the scrollbar track starts
84
+ * @param {number} config.scrollBarTrackHeight - Total height available for the scrollbar track
85
+ * @param {number} [config.scrollBarThumbStartY] - Y coordinate where scrollbar thumb should start (optional, calculated if not provided)
86
+ * @param {string} [config.backgroundColor] - Background fill color for the content area (default: "rgba(40, 40, 40, 0.8)")
87
+ * @param {string} [config.borderColor] - Border color for the content area (default: "rgba(255, 255, 255, 0.3)")
88
+ * @param {number} [config.borderWidth] - Border thickness for the content area (default: 2)
89
+ * @param {number} [config.cornerRadius] - Corner radius for rounded background corners (default: 0)
90
+ * @param {boolean} [config.drawBackground] - Whether to draw the background automatically (default: true)
91
+ *
92
+ * @param {Object} input - Reference to the ActionEngine input system for handling user interactions
93
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D rendering context for drawing the scrollbar components
94
+ *
95
+ * @example
96
+ * // Basic setup - component handles background automatically
97
+ * const inventoryScroller = new ActionScrollableArea({
98
+ * listAreaX: 50, listAreaY: 100, listAreaWidth: 300, listAreaHeight: 400,
99
+ * itemHeight: 60, scrollBarX: 360, scrollBarY: 100, scrollBarTrackHeight: 400,
100
+ *
101
+ * // Optional: customize background (defaults are sensible)
102
+ * backgroundColor: "rgba(40, 40, 40, 0.8)",
103
+ * borderColor: "rgba(255, 255, 255, 0.3)"
104
+ * }, game.input, game.guiCtx);
105
+ *
106
+ * // Advanced setup with custom styling and background
107
+ * const customScroller = new ActionScrollableArea({
108
+ * listAreaX: 400, listAreaY: 100, listAreaWidth: 450, listAreaHeight: 400,
109
+ * itemHeight: 120, scrollBarX: 860, scrollBarY: 90,
110
+ * scrollBarTrackHeight: 380, scrollBarThumbStartY: 120,
111
+ *
112
+ * // Enable clipping for precise input bounds
113
+ * enableClipping: true,
114
+ * clipBounds: { x: 400, y: 100, width: 450, height: 400 },
115
+ *
116
+ * // Custom scrollbar colors for theme consistency
117
+ * colors: {
118
+ * track: { normal: "rgba(255, 255, 255, 0.1)", hover: "rgba(255, 255, 255, 0.2)" },
119
+ * thumb: { normal: "rgba(255, 100, 100, 0.3)", hover: "rgba(255, 100, 100, 0.6)", drag: "rgba(255, 100, 100, 0.8)" },
120
+ * button: { normal: "rgba(255, 100, 100, 0.1)", hover: "rgba(255, 100, 100, 0.3)" },
121
+ * buttonText: { normal: "rgba(255, 100, 100, 0.8)", hover: "#ff6464" },
122
+ * thumbBorder: { normal: "rgba(255, 100, 100, 0.5)", drag: "#ff6464" }
123
+ * },
124
+ *
125
+ * // Self-contained background styling - no manual drawing needed!
126
+ * drawBackground: true, // Enable automatic background
127
+ * backgroundColor: "rgba(20, 20, 30, 0.9)", // Dark blue background
128
+ * borderColor: "rgba(255, 100, 100, 0.6)", // Red border with 60% opacity
129
+ * borderWidth: 3, // Thicker border
130
+ * cornerRadius: 8 // Rounded corners
131
+ * }, game.input, game.guiCtx);
132
+ *
133
+ * // Chat log scroller with different ID format
134
+ * const chatScroller = new ActionScrollableArea({
135
+ * listAreaX: 50, listAreaY: 100, listAreaWidth: 300, listAreaHeight: 400,
136
+ * itemHeight: 40, scrollBarX: 360, scrollBarY: 100, scrollBarTrackHeight: 400,
137
+ *
138
+ * // Generate chat-specific IDs
139
+ * generateItemId: (message, index) => `chat_msg_${message.timestamp}_${index}`,
140
+ *
141
+ * // Enable clipping for chat area
142
+ * enableClipping: true,
143
+ * clipBounds: { x: 50, y: 100, width: 300, height: 400 }
144
+ * }, game.input, game.guiCtx);
145
+ */
146
+ constructor(config, input, ctx) {
147
+ this.input = input;
148
+ this.ctx = ctx;
149
+
150
+ // Scroll state
151
+ this.scrollOffset = 0;
152
+ this.maxScrollOffset = 0;
153
+ this.isDragging = false;
154
+ this.dragStartY = 0;
155
+ this.lastScrollOffset = 0;
156
+ this.dragThreshold = 5;
157
+ this.hasMovedBeyondThreshold = false;
158
+
159
+ // Track scroll and content changes to prevent unnecessary input re-registration
160
+ this.lastScrollOffsetForInput = -1; // Initialize to -1 so first update always triggers
161
+ this.lastItemCountForInput = -1; // Track item count changes for new insertions/deletions
162
+ this.registeredItems = new Set(); // Track which items are currently registered
163
+
164
+ // List area dimensions
165
+ this.listArea = {
166
+ x: config.listAreaX || 400,
167
+ y: config.listAreaY || 100,
168
+ width: config.listAreaWidth || 450,
169
+ height: config.listAreaHeight || 400,
170
+ itemHeight: config.itemHeight || 60,
171
+ padding: config.padding !== undefined ? config.padding : 8,
172
+ scrollBarWidth: 20
173
+ };
174
+
175
+ // Scrollbar positioning
176
+ this.scrollArea = {
177
+ x: config.scrollBarX || 860,
178
+ y: config.scrollBarY || 90,
179
+ trackHeight: config.scrollBarTrackHeight || 380,
180
+ thumbStartY: config.scrollBarThumbStartY || 120
181
+ };
182
+
183
+ // Configurable colors with sensible defaults
184
+ // COLOR CONFIGURATION STRUCTURE:
185
+ // - track: Scrollbar track background {normal, hover}
186
+ // - thumb: Scrollbar thumb {normal, hover, drag}
187
+ // - button: Up/Down button backgrounds {normal, hover}
188
+ // - buttonText: Button text colors {normal, hover}
189
+ // - thumbBorder: Thumb border colors {normal, drag}
190
+ this.colors = {
191
+ track: {
192
+ normal: "rgba(0, 0, 0, 0.2)",
193
+ hover: "rgba(0, 0, 0, 0.3)"
194
+ },
195
+ thumb: {
196
+ normal: "rgba(52, 152, 219, 0.3)",
197
+ hover: "rgba(52, 152, 219, 0.6)",
198
+ drag: "rgba(52, 152, 219, 0.8)"
199
+ },
200
+ button: {
201
+ normal: "rgba(52, 152, 219, 0.1)",
202
+ hover: "rgba(52, 152, 219, 0.3)"
203
+ },
204
+ buttonText: {
205
+ normal: "rgba(52, 152, 219, 0.8)",
206
+ hover: "#3498DB"
207
+ },
208
+ thumbBorder: {
209
+ normal: "rgba(52, 152, 219, 0.5)",
210
+ drag: "#3498DB"
211
+ }
212
+ };
213
+
214
+ // Override with custom colors if provided
215
+ if (config.colors) {
216
+ Object.assign(this.colors, config.colors);
217
+ }
218
+
219
+ // Clipping support (fundamental feature)
220
+ this.clipBounds = config.clipBounds || null; // {x, y, width, height} for clipping
221
+ this.enableClipping = config.enableClipping || false;
222
+
223
+ // Custom input registration callback
224
+ this.onRegisterInput =
225
+ config.onRegisterInput ||
226
+ ((id, bounds, layer = "gui") => {
227
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
228
+ this.input.registerElement(id, { bounds: () => bounds }, layer);
229
+ }
230
+ });
231
+
232
+ // Generate item ID for input registration (configurable)
233
+ this.generateItemId = config.generateItemId || ((item, index) => `item_${index}`);
234
+
235
+ // Custom item input registration callback (stable registration)
236
+ this.onRegisterItemInput =
237
+ config.onRegisterItemInput ||
238
+ ((itemId, index, bounds) => {
239
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
240
+ // Always register visible items - the registration system handles duplicates
241
+ this.onRegisterInput(itemId, bounds);
242
+ }
243
+ });
244
+
245
+ // Background configuration
246
+ this.drawBackground = config.drawBackground !== false; // Default to true
247
+ this.backgroundConfig = {
248
+ fillColor: config.backgroundColor || "rgba(40, 40, 40, 0.8)",
249
+ borderColor: config.borderColor || "rgba(255, 255, 255, 0.3)",
250
+ borderWidth: config.borderWidth || 2,
251
+ cornerRadius: config.cornerRadius || 0 // For rounded corners
252
+ };
253
+
254
+ const scrollButtonWidth = 20;
255
+ const scrollButtonHeight = 20;
256
+
257
+ // Scroll buttons (using configurable colors)
258
+ this.scrollUpButton = {
259
+ width: scrollButtonWidth,
260
+ height: scrollButtonHeight,
261
+ x: this.scrollArea.x,
262
+ y: this.scrollArea.y - scrollButtonHeight,
263
+ text: "▲",
264
+ color: this.colors.button.normal,
265
+ hovered: false
266
+ };
267
+
268
+ this.scrollDownButton = {
269
+ x: this.scrollArea.x,
270
+ y: this.scrollArea.y + this.scrollArea.trackHeight,
271
+ width: scrollButtonWidth,
272
+ height: scrollButtonHeight,
273
+ text: "▼",
274
+ color: this.colors.button.normal,
275
+ hovered: false
276
+ };
277
+
278
+ this.scrollThumb = {
279
+ x: this.scrollArea.x,
280
+ y: this.scrollArea.thumbStartY,
281
+ width: 20,
282
+ height: 60,
283
+ color: this.colors.thumb.normal,
284
+ hovered: false
285
+ };
286
+
287
+ // Element IDs for input registration
288
+ this.elementIds = {
289
+ scrollUp: this.generateElementId(),
290
+ scrollDown: this.generateElementId(),
291
+ scrollbarTrack: this.generateElementId()
292
+ };
293
+
294
+ this.setupInput();
295
+ this.setupMouseWheel();
296
+ }
297
+
298
+ /**
299
+ * Generates a unique element ID for input system registration
300
+ *
301
+ * Creates a random string identifier to avoid conflicts when multiple scrollable
302
+ * areas are used in the same application. Uses base-36 encoding for compactness.
303
+ *
304
+ * @returns {string} A unique identifier string for input element registration
305
+ *
306
+ * @private
307
+ * @example
308
+ * // Returns something like: "scrollable_a4f2k1"
309
+ * const elementId = this.generateElementId();
310
+ */
311
+ generateElementId() {
312
+ return `scrollable_${Math.random().toString(36).substr(2, 9)}`;
313
+ }
314
+
315
+ /**
316
+ * Sets up input system registration for scrollbar interactive elements
317
+ *
318
+ * Registers three interactive elements with the input system:
319
+ * - scrollbarTrack: The background area of the scrollbar (for click-to-jump)
320
+ * - scrollUp: The up arrow button for incremental scrolling
321
+ * - scrollDown: The down arrow button for incremental scrolling
322
+ *
323
+ * Each element is registered with dynamic bounds calculation that updates
324
+ * in real-time to match the current scrollbar position and dimensions.
325
+ *
326
+ * @private
327
+ * @example
328
+ * // Automatically called during construction
329
+ * this.setupInput(); // Registers "scrollUp", "scrollDown", and "scrollbarTrack"
330
+ */
331
+ setupInput() {
332
+ // Register scrollbar track for click-to-jump and dragging
333
+ this.input.registerElement(
334
+ this.elementIds.scrollbarTrack,
335
+ {
336
+ bounds: () => ({
337
+ x: this.scrollArea.x,
338
+ y: this.scrollArea.thumbStartY,
339
+ width: 20,
340
+ height: this.scrollArea.trackHeight
341
+ })
342
+ },
343
+ "gui"
344
+ );
345
+
346
+ // Register scroll up button
347
+ this.input.registerElement(
348
+ this.elementIds.scrollUp,
349
+ {
350
+ bounds: () => ({
351
+ x: this.scrollUpButton.x,
352
+ y: this.scrollUpButton.y,
353
+ width: this.scrollUpButton.width,
354
+ height: this.scrollUpButton.height
355
+ })
356
+ },
357
+ "gui"
358
+ );
359
+
360
+ // Register scroll down button
361
+ this.input.registerElement(
362
+ this.elementIds.scrollDown,
363
+ {
364
+ bounds: () => ({
365
+ x: this.scrollDownButton.x,
366
+ y: this.scrollDownButton.y,
367
+ width: this.scrollDownButton.width,
368
+ height: this.scrollDownButton.height
369
+ })
370
+ },
371
+ "gui"
372
+ );
373
+ }
374
+
375
+ /**
376
+ * Sets up automatic mouse wheel event handling for seamless scrolling
377
+ *
378
+ * This method adds wheel event listeners to both the game canvas and window
379
+ * as a fallback. The event listeners are added with a small delay to ensure
380
+ * the canvas element exists in the DOM before attempting to attach listeners.
381
+ *
382
+ * Features:
383
+ * - Prevents default browser scroll behavior when over canvas
384
+ * - Handles both positive and negative delta values
385
+ * - Provides fallback to window if canvas is not available
386
+ * - Automatically removes events when component is destroyed
387
+ *
388
+ * @private
389
+ * @example
390
+ * // Automatically called during construction with 100ms delay
391
+ * setTimeout(() => {
392
+ * canvas.addEventListener("wheel", (e) => {
393
+ * this.handleMouseWheel(e.deltaY);
394
+ * });
395
+ * }, 100);
396
+ */
397
+ setupMouseWheel() {
398
+ setTimeout(() => {
399
+ const canvas = document.querySelector("#gameCanvas");
400
+ if (canvas) {
401
+ // Explicitly mark as non-passive since we call preventDefault()
402
+ canvas.addEventListener(
403
+ "wheel",
404
+ (e) => {
405
+ e.preventDefault();
406
+ this.handleMouseWheel(e.deltaY);
407
+ },
408
+ { passive: false }
409
+ );
410
+
411
+ // Fallback to window: does not call preventDefault, so can be passive
412
+ window.addEventListener(
413
+ "wheel",
414
+ (e) => {
415
+ this.handleMouseWheel(e.deltaY);
416
+ },
417
+ { passive: true }
418
+ );
419
+ }
420
+ }, 100);
421
+ }
422
+
423
+ /**
424
+ * Updates the scrollable area state and handles all scrolling interactions
425
+ *
426
+ * This is the main update method that should be called every frame in your game loop.
427
+ * It performs several critical functions:
428
+ * - Updates maximum scroll range based on item count
429
+ * - Recalculates scrollbar thumb position and size
430
+ * - Processes user input (clicks, drags, keyboard)
431
+ * - Handles boundary constraints
432
+ *
433
+ * Call this method before drawing to ensure scrollbar state is current.
434
+ *
435
+ * @param {number} totalItemCount - Total number of items in the scrollable list
436
+ * @param {number} deltaTime - Time elapsed since last frame in seconds (for smooth keyboard scrolling)
437
+ *
438
+ * @example
439
+ * // In your game update loop
440
+ * update(deltaTime) {
441
+ * // Update scrollable area before processing input
442
+ * this.inventoryScroller.update(this.gameState.inventory.length, deltaTime);
443
+ * }
444
+ */
445
+ update(totalItemCount, deltaTime) {
446
+ this.updateMaxScroll(totalItemCount);
447
+ this.updateScrollbarThumb();
448
+ this.handleInput(deltaTime);
449
+ }
450
+
451
+ /**
452
+ * Updates the maximum scroll offset based on total item count and visible area
453
+ *
454
+ * Calculates how far the user can scroll by comparing the total content height
455
+ * (itemCount × itemHeight) with the visible area height. The difference represents
456
+ * the maximum scroll distance needed to see all content.
457
+ *
458
+ * Formula: maxScroll = (totalItems × itemHeight) - visibleAreaHeight
459
+ * Example: 20 items × 60px = 1200px total height, 400px visible area = 800px max scroll
460
+ *
461
+ * @param {number} totalItemCount - Total number of items in the list
462
+ *
463
+ * @private
464
+ * @example
465
+ * // 100 items × 50px each = 5000px total height
466
+ * // 300px visible area = 4700px maximum scroll
467
+ * this.updateMaxScroll(100);
468
+ * console.log(this.maxScrollOffset); // 4700
469
+ */
470
+ updateMaxScroll(totalItemCount) {
471
+ const totalContentHeight = totalItemCount * (this.listArea.itemHeight + this.listArea.padding);
472
+ const visibleHeight = this.listArea.height;
473
+ this.maxScrollOffset = Math.max(0, totalContentHeight - visibleHeight);
474
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset));
475
+ }
476
+
477
+ /**
478
+ * Updates the scrollbar thumb position and size based on current scroll state
479
+ *
480
+ * This method implements proportional scrollbar behavior where:
481
+ * - Thumb height represents the ratio of visible content to total content
482
+ * - Thumb position represents the current scroll progress through the content
483
+ * - Thumb moves smoothly as user scrolls through the content
484
+ *
485
+ * The scrollbar provides visual feedback about:
486
+ * 1. How much content exists (thumb height = visible/total ratio)
487
+ * 2. Where you are in the content (thumb position = scroll progress)
488
+ * 3. Content boundaries (thumb stops at track limits)
489
+ *
490
+ * @private
491
+ * @example
492
+ * // Content: 1000px total, 400px visible = 40% thumb height
493
+ * // Scrolled 250px = 25% progress = thumb at 25% down the track
494
+ * this.scrollOffset = 250;
495
+ * this.updateScrollbarThumb();
496
+ * console.log(this.scrollThumb.height); // ~40% of track height
497
+ * console.log(this.scrollThumb.y); // 25% down from track start
498
+ */
499
+ updateScrollbarThumb() {
500
+ const trackHeight = this.scrollArea.trackHeight;
501
+
502
+ // Calculate total content height (all items that exist)
503
+ const totalItemCount =
504
+ this.maxScrollOffset / (this.listArea.itemHeight + this.listArea.padding) +
505
+ this.listArea.height / (this.listArea.itemHeight + this.listArea.padding);
506
+ const totalContentHeight = totalItemCount * (this.listArea.itemHeight + this.listArea.padding);
507
+
508
+ // Thumb height represents the ratio of visible content to total content
509
+ const thumbHeightRatio = this.listArea.height / totalContentHeight;
510
+ this.scrollThumb.height = Math.max(30, trackHeight * thumbHeightRatio);
511
+
512
+ // Calculate thumb position based on current scroll progress
513
+ const currentScrollProgress = this.maxScrollOffset > 0 ? this.scrollOffset / this.maxScrollOffset : 0;
514
+ const availableTrackSpace = trackHeight - this.scrollThumb.height;
515
+ this.scrollThumb.y = this.scrollArea.thumbStartY + availableTrackSpace * currentScrollProgress;
516
+
517
+ // Ensure thumb stays within track bounds
518
+ const maxThumbY = this.scrollArea.thumbStartY + trackHeight - this.scrollThumb.height;
519
+ this.scrollThumb.y = Math.max(this.scrollArea.thumbStartY, Math.min(maxThumbY, this.scrollThumb.y));
520
+ }
521
+
522
+ /**
523
+ * Handles all user input events for the scrollable area
524
+ *
525
+ * This is the central input handling method that processes:
526
+ * - Scroll up/down button clicks (incremental scrolling)
527
+ * - Scrollbar track clicks (click-to-jump functionality)
528
+ * - Thumb dragging (smooth continuous scrolling)
529
+ * - Keyboard navigation when hovering over content area
530
+ *
531
+ * The method updates hover states for visual feedback and coordinates
532
+ * all scrolling interactions into smooth, responsive movement.
533
+ *
534
+ * @param {number} deltaTime - Time elapsed since last frame for smooth keyboard scrolling
535
+ *
536
+ * @private
537
+ * @example
538
+ * // Called automatically by update() - no manual invocation needed
539
+ * handleInput(deltaTime) {
540
+ * // Handles button clicks, dragging, and keyboard input
541
+ * // Updates this.scrollOffset based on user interactions
542
+ * }
543
+ */
544
+ handleInput(deltaTime) {
545
+ const pointer = this.input.getPointerPosition();
546
+
547
+ // Handle scroll up/down buttons
548
+ if (this.input.isElementJustPressed(this.elementIds.scrollUp, "gui")) {
549
+ this.scrollUp();
550
+ }
551
+
552
+ if (this.input.isElementJustPressed(this.elementIds.scrollDown, "gui")) {
553
+ this.scrollDown();
554
+ }
555
+
556
+ // Update hover states
557
+ this.scrollUpButton.hovered = this.input.isElementHovered(this.elementIds.scrollUp, "gui");
558
+ this.scrollDownButton.hovered = this.input.isElementHovered(this.elementIds.scrollDown, "gui");
559
+
560
+ // Handle dragging
561
+ this.handleDragging(pointer);
562
+
563
+ // Handle track clicks for click-to-jump functionality
564
+ if (this.input.isElementJustPressed(this.elementIds.scrollbarTrack, "gui")) {
565
+ const trackY = pointer.y - this.scrollArea.thumbStartY;
566
+ const scrollableHeight = this.scrollArea.trackHeight;
567
+ const jumpPercent = Math.max(0, Math.min(1, trackY / scrollableHeight));
568
+
569
+ this.scrollOffset = jumpPercent * this.maxScrollOffset;
570
+ }
571
+
572
+ // Handle keyboard scrolling when hovering over list area
573
+ this.handleKeyboardScrolling(deltaTime);
574
+ }
575
+
576
+ /**
577
+ * Handles mouse wheel scroll events for smooth scrolling
578
+ *
579
+ * Processes wheel delta values to determine scroll direction and amount.
580
+ * Positive delta values scroll down (show content below), negative values
581
+ * scroll up (show content above). The scroll amount is fixed at 60 pixels
582
+ * per wheel "notch" for consistent behavior across different mice.
583
+ *
584
+ * @param {number} deltaY - Wheel delta value (positive = scroll down, negative = scroll up)
585
+ *
586
+ * @private
587
+ * @example
588
+ * // Mouse wheel scrolled down (deltaY = 120)
589
+ * this.handleMouseWheel(120);
590
+ * this.scrollOffset += 60; // Scroll down by one "notch"
591
+ *
592
+ * // Mouse wheel scrolled up (deltaY = -120)
593
+ * this.handleMouseWheel(-120);
594
+ * this.scrollOffset -= 60; // Scroll up by one "notch"
595
+ */
596
+ handleMouseWheel(deltaY) {
597
+ const scrollAmount = deltaY > 0 ? 20 : -20; // ← Changed from 60 to 20
598
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset + scrollAmount));
599
+ }
600
+
601
+ /**
602
+ * Scrolls content up by exactly one item height
603
+ *
604
+ * Provides incremental scrolling up, moving the view to show content
605
+ * that was previously above the visible area. The scroll amount equals
606
+ * the itemHeight specified in configuration.
607
+ *
608
+ * @example
609
+ * // If itemHeight = 60, scrolls up by exactly 60 pixels
610
+ * this.scrollUp();
611
+ * // Content moves up, showing items that were above the visible area
612
+ */
613
+ scrollUp() {
614
+ const scrollAmount = 50; // ← Changed from itemHeight (120) to 50
615
+ this.scrollOffset = Math.max(0, this.scrollOffset - scrollAmount);
616
+ }
617
+
618
+ /**
619
+ * Scrolls content down by exactly one item height
620
+ *
621
+ * Provides incremental scrolling down, moving the view to show content
622
+ * that was previously below the visible area. The scroll amount equals
623
+ * the itemHeight specified in configuration.
624
+ *
625
+ * @example
626
+ * // If itemHeight = 60, scrolls down by exactly 60 pixels
627
+ * this.scrollDown();
628
+ * // Content moves down, showing items that were below the visible area
629
+ */
630
+ scrollDown() {
631
+ const scrollAmount = 50; // ← Changed from itemHeight (120) to 50
632
+ this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + scrollAmount);
633
+ }
634
+
635
+ /**
636
+ * Handles scrollbar thumb dragging with threshold-based drag detection
637
+ *
638
+ * This method implements sophisticated drag behavior:
639
+ * 1. Detects when user clicks directly on the scrollbar thumb
640
+ * 2. Waits for movement beyond threshold before considering it a drag
641
+ * 3. Calculates scroll position based on thumb position in real-time
642
+ * 4. Provides smooth, continuous scrolling during drag operation
643
+ *
644
+ * The drag threshold prevents accidental drags from single clicks and
645
+ * ensures intentional scrolling behavior.
646
+ *
647
+ * @param {Object} pointer - Current pointer position from input system
648
+ * @param {number} pointer.x - X coordinate of mouse/touch
649
+ * @param {number} pointer.y - Y coordinate of mouse/touch
650
+ *
651
+ * @private
652
+ * @example
653
+ * // User clicks on thumb at position 200, drags to 250
654
+ * handleDragging({x: 100, y: 250});
655
+ * // Calculates: deltaY = 50, converts to scroll percentage
656
+ * // Updates this.scrollOffset proportionally
657
+ */
658
+ handleDragging(pointer) {
659
+ // Check if clicking directly on thumb
660
+ const isOverThumb =
661
+ pointer.x >= this.scrollThumb.x &&
662
+ pointer.x <= this.scrollThumb.x + this.scrollThumb.width &&
663
+ pointer.y >= this.scrollThumb.y &&
664
+ pointer.y <= this.scrollThumb.y + this.scrollThumb.height;
665
+
666
+ if (this.input.isPointerJustDown() && isOverThumb) {
667
+ this.isDragging = true;
668
+ this.dragStartY = pointer.y;
669
+ this.lastScrollOffset = this.scrollOffset;
670
+ this.hasMovedBeyondThreshold = false;
671
+ }
672
+
673
+ // Handle dragging
674
+ if (this.isDragging && this.input.isPointerDown()) {
675
+ const deltaY = pointer.y - this.dragStartY;
676
+
677
+ if (Math.abs(deltaY) > this.dragThreshold) {
678
+ this.hasMovedBeyondThreshold = true;
679
+
680
+ // Calculate scroll based on mouse position relative to track
681
+ const trackY = pointer.y - this.scrollArea.thumbStartY;
682
+ const availableTrackHeight = this.scrollArea.trackHeight - this.scrollThumb.height;
683
+ const scrollPercent = Math.max(0, Math.min(1, trackY / Math.max(1, availableTrackHeight)));
684
+
685
+ this.scrollOffset = scrollPercent * this.maxScrollOffset;
686
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset));
687
+ }
688
+ } else {
689
+ this.isDragging = false;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Handles keyboard scrolling when pointer is hovering over the list area
695
+ *
696
+ * Provides keyboard navigation for users who prefer keyboard input or
697
+ * accessibility. Only activates when the pointer is within the list area
698
+ * bounds to avoid conflicts with other keyboard handlers.
699
+ *
700
+ * Features:
701
+ * - DirUp key scrolls up smoothly based on deltaTime
702
+ * - DirDown key scrolls down smoothly based on deltaTime
703
+ * - Respects scroll boundaries (won't scroll past limits)
704
+ * - Smooth scrolling speed (300 pixels per second)
705
+ *
706
+ * @param {number} deltaTime - Time elapsed since last frame for consistent scroll speed
707
+ *
708
+ * @private
709
+ * @example
710
+ * // Pointer at (450, 200) - within list area (400-850, 100-500)
711
+ * // User presses DirUp key
712
+ * handleKeyboardScrolling(0.016); // ~60fps
713
+ * this.scrollOffset -= 300 * 0.016; // Scroll up ~4.8 pixels
714
+ */
715
+ handleKeyboardScrolling(deltaTime) {
716
+ const pointer = this.input.getPointerPosition();
717
+ const isOverListArea =
718
+ pointer.x >= this.listArea.x &&
719
+ pointer.x <= this.listArea.x + this.listArea.width - this.listArea.scrollBarWidth - 10 &&
720
+ pointer.y >= this.listArea.y &&
721
+ pointer.y <= this.listArea.y + this.listArea.height;
722
+
723
+ if (isOverListArea) {
724
+ if (this.input.isKeyPressed("DirUp")) {
725
+ this.scrollOffset = Math.max(0, this.scrollOffset - 300 * deltaTime);
726
+ }
727
+ if (this.input.isKeyPressed("DirDown")) {
728
+ this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + 300 * deltaTime);
729
+ }
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Handles click-to-jump functionality on the scrollbar track
735
+ *
736
+ * When user clicks on the scrollbar track (not on the thumb), the content
737
+ * immediately jumps to show the portion of content at that relative position.
738
+ * This provides quick navigation to different parts of long content.
739
+ *
740
+ * Algorithm:
741
+ * 1. Calculate click position relative to track start
742
+ * 2. Convert to percentage of total track height
743
+ * 3. Apply percentage to total scrollable content height
744
+ * 4. Jump directly to that scroll position
745
+ *
746
+ * @param {Object} pointer - Current pointer position from input system
747
+ * @param {number} pointer.y - Y coordinate of the click
748
+ *
749
+ * @example
750
+ * // User clicks 50% down the scrollbar track
751
+ * handleTrackClick({x: 100, y: 200});
752
+ * // If trackY = 100px from start, scrollableHeight = 300px
753
+ * // jumpPercent = 100/300 = 0.33
754
+ * // If maxScroll = 600px, new scrollOffset = 0.33 * 600 = 200px
755
+ */
756
+ handleTrackClick(pointer) {
757
+ if (this.input.isElementJustPressed(this.elementIds.scrollbarTrack, "gui")) {
758
+ const trackY = pointer.y - this.scrollArea.thumbStartY;
759
+ const scrollableHeight = this.scrollArea.trackHeight - 30;
760
+ const jumpPercent = Math.max(0, Math.min(1, trackY / scrollableHeight));
761
+
762
+ this.scrollOffset = jumpPercent * this.maxScrollOffset;
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Checks if an item at the given index is visible in the current scroll view
768
+ *
769
+ * Determines whether an item should be drawn by checking if its calculated
770
+ * position falls within the visible area boundaries. This prevents drawing
771
+ * items that are scrolled out of view, improving performance with large lists.
772
+ *
773
+ * The check uses the same positioning calculation as getItemDrawY() to ensure
774
+ * consistency between visibility testing and actual drawing positions.
775
+ *
776
+ * @param {number} index - Zero-based index of the item to check
777
+ * @param {Object} customClipBounds - Optional custom clip bounds to override default
778
+ * @returns {boolean} true if item is visible, false if scrolled out of view
779
+ *
780
+ * @example
781
+ * // Basic usage (no clipping)
782
+ * const visible = isItemVisible(5); // true
783
+ *
784
+ * // With custom clipping
785
+ * const visible = isItemVisible(5, {x: 400, y: 100, width: 450, height: 400});
786
+ */
787
+ isItemVisible(index, customClipBounds = null) {
788
+ if (this.enableClipping) {
789
+ const itemBounds = this.getItemBounds(index);
790
+ const clipBounds = customClipBounds || this.clipBounds;
791
+ return this.calculateIntersection(itemBounds, clipBounds).area > 0;
792
+ }
793
+
794
+ // Fallback to original logic when clipping is disabled
795
+ const itemY = this.listArea.y + 10 + index * this.listArea.itemHeight - this.scrollOffset;
796
+ const itemBottom = itemY + this.listArea.itemHeight;
797
+ const visibleTop = this.listArea.y;
798
+ const visibleBottom = this.listArea.y + this.listArea.height;
799
+
800
+ // Item is visible if any part of it intersects with the visible area
801
+ return !(itemBottom <= visibleTop || itemY >= visibleBottom);
802
+ }
803
+
804
+ /**
805
+ * Gets the correct Y position for drawing an item at the given index
806
+ *
807
+ * Calculates the exact screen position where an item should be drawn,
808
+ * taking into account the current scroll offset. This ensures items
809
+ * move smoothly as the user scrolls through the content.
810
+ *
811
+ * Formula: itemY = listArea.y + padding + (index × itemHeight) - scrollOffset
812
+ *
813
+ * This method is crucial for synchronizing item drawing positions with
814
+ * input handling and scrollbar visual feedback.
815
+ *
816
+ * @param {number} index - Zero-based index of the item
817
+ * @returns {number} Y coordinate where the item should be drawn
818
+ *
819
+ * @example
820
+ * // List configuration: listArea.y = 100, itemHeight = 60
821
+ * // Item at index 3, scrolled down by 120 pixels
822
+ * const y = getItemDrawY(3);
823
+ * // y = 100 + 10 + (3 * 60) - 120 = 100 + 10 + 180 - 120 = 170
824
+ * // Item appears at screen coordinate 170
825
+ */
826
+ getItemDrawY(index) {
827
+ return (
828
+ this.listArea.y +
829
+ this.listArea.padding +
830
+ index * (this.listArea.itemHeight + this.listArea.padding) -
831
+ this.scrollOffset
832
+ );
833
+ }
834
+
835
+ /**
836
+ * Gets the full bounds of an item (before clipping)
837
+ *
838
+ * @param {number} index - Zero-based index of the item
839
+ * @returns {Object} Item bounds {x, y, width, height}
840
+ */
841
+ getItemBounds(index) {
842
+ return {
843
+ x: this.listArea.x + 10,
844
+ y: this.getItemDrawY(index),
845
+ width: this.listArea.width - 20,
846
+ height: this.listArea.itemHeight
847
+ };
848
+ }
849
+
850
+ /**
851
+ * Gets the clipped bounds of an item within the clip region
852
+ * Only returns bounds if the item intersects with the visible area
853
+ *
854
+ * @param {number} index - Zero-based index of the item
855
+ * @param {Object} customClipBounds - Optional custom clip bounds
856
+ * @returns {Object|null} Clipped bounds {x, y, width, height} or null if not visible
857
+ */
858
+ getClippedItemBounds(index, customClipBounds = null) {
859
+ if (!this.enableClipping) {
860
+ return this.getItemBounds(index); // No clipping, return full bounds
861
+ }
862
+
863
+ const itemBounds = this.getItemBounds(index);
864
+ const clipBounds = customClipBounds || this.clipBounds;
865
+
866
+ if (!clipBounds) {
867
+ return itemBounds; // No clip bounds defined, return full bounds
868
+ }
869
+
870
+ const intersection = this.calculateIntersection(itemBounds, clipBounds);
871
+ return intersection.area > 0 ? intersection.bounds : null;
872
+ }
873
+
874
+ /**
875
+ * Calculates rectangle intersection between two bounds
876
+ *
877
+ * @param {Object} bounds1 - First bounds {x, y, width, height}
878
+ * @param {Object} bounds2 - Second bounds {x, y, width, height}
879
+ * @returns {Object} Intersection result {bounds, area}
880
+ */
881
+ calculateIntersection(bounds1, bounds2) {
882
+ const x1 = Math.max(bounds1.x, bounds2.x);
883
+ const y1 = Math.max(bounds1.y, bounds2.y);
884
+ const x2 = Math.min(bounds1.x + bounds1.width, bounds2.x + bounds2.width);
885
+ const y2 = Math.min(bounds1.y + bounds1.height, bounds2.y + bounds2.height);
886
+
887
+ const width = Math.max(0, x2 - x1);
888
+ const height = Math.max(0, y2 - y1);
889
+ const area = width * height;
890
+
891
+ return {
892
+ bounds: width > 0 && height > 0 ? { x: x1, y: y1, width, height } : null,
893
+ area: area
894
+ };
895
+ }
896
+
897
+ /**
898
+ * Registers input for a specific item with proper clipping
899
+ * Intelligently handles registration based on visibility and current state
900
+ *
901
+ * @param {Object} item - The item object
902
+ * @param {number} index - Index of the item in the list
903
+ * @param {string} layer - Input layer (default: 'gui')
904
+ */
905
+ registerItemInput(item, index, layer = "gui") {
906
+ const itemId = this.generateItemId(item, index);
907
+ const isCurrentlyRegistered = this.registeredItems.has(itemId);
908
+ const clippedBounds = this.getClippedItemBounds(index);
909
+
910
+ if (clippedBounds && clippedBounds.width > 0 && clippedBounds.height > 0) {
911
+ // Item is visible - register if not already registered
912
+ if (!isCurrentlyRegistered) {
913
+ this.onRegisterItemInput(itemId, index, clippedBounds, layer);
914
+ this.registeredItems.add(itemId);
915
+ }
916
+ } else {
917
+ // Item is not visible - remove registration if it exists
918
+ if (isCurrentlyRegistered) {
919
+ this.input.removeElement(itemId, layer);
920
+ this.registeredItems.delete(itemId);
921
+ }
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Updates input registrations for all visible items
927
+ * Intelligently handles registration based on visibility and state changes
928
+ *
929
+ * @param {Array} items - Array of items to register input for
930
+ * @param {string} layer - Input layer (default: 'gui')
931
+ */
932
+ updateItemInputs(items, layer = "gui") {
933
+ const currentItemCount = items.length;
934
+
935
+ // Only update if scroll position OR item count changed
936
+ if (this.scrollOffset === this.lastScrollOffsetForInput && currentItemCount === this.lastItemCountForInput) {
937
+ return; // No changes, skip update
938
+ }
939
+
940
+ // For item count changes, we need to be more selective
941
+ // Only clear items that are no longer valid or visible
942
+ if (currentItemCount !== this.lastItemCountForInput) {
943
+ this.cleanupInvalidRegistrations(items, layer);
944
+ }
945
+
946
+ // Update registrations for all items (this will add new ones and remove invisible ones)
947
+ items.forEach((item, index) => {
948
+ this.registerItemInput(item, index, layer);
949
+ });
950
+
951
+ // Update tracking
952
+ this.lastScrollOffsetForInput = this.scrollOffset;
953
+ this.lastItemCountForInput = currentItemCount;
954
+ }
955
+
956
+ /**
957
+ * Cleans up registrations for items that are no longer valid
958
+ * More intelligent than clearing everything - only removes truly invalid items
959
+ *
960
+ * @param {Array} items - Current array of valid items
961
+ * @param {string} layer - Input layer
962
+ */
963
+ cleanupInvalidRegistrations(items, layer = "gui") {
964
+ // Create a set of current valid item IDs
965
+ const validItemIds = new Set();
966
+ items.forEach((item, index) => {
967
+ validItemIds.add(this.generateItemId(item, index));
968
+ });
969
+
970
+ // Remove registrations for items that are no longer in the valid set
971
+ const itemsToRemove = [];
972
+ this.registeredItems.forEach((itemId) => {
973
+ if (!validItemIds.has(itemId)) {
974
+ itemsToRemove.push(itemId);
975
+ }
976
+ });
977
+
978
+ // Remove the invalid items
979
+ itemsToRemove.forEach((itemId) => {
980
+ this.input.removeElement(itemId, layer);
981
+ this.registeredItems.delete(itemId);
982
+ });
983
+ }
984
+
985
+ /**
986
+ * Forces an input update for all items (useful when items are added/removed)
987
+ * Call this when the item array changes outside of normal scroll updates
988
+ *
989
+ * @param {Array} items - Array of items to register input for
990
+ * @param {string} layer - Input layer (default: 'gui')
991
+ */
992
+ refreshItems(items, layer = "gui") {
993
+ // Clear ALL existing item registrations from input system first
994
+ this.clearAllItemInputs(layer);
995
+
996
+ // Reset tracking to force update
997
+ this.lastScrollOffsetForInput = -1;
998
+ this.lastItemCountForInput = -1;
999
+ this.registeredItems.clear();
1000
+
1001
+ // Update with new items
1002
+ this.updateItemInputs(items, layer);
1003
+
1004
+ // Re-detect hover state after refresh
1005
+ this.reDetectHoverAfterRefresh(items, layer);
1006
+ }
1007
+
1008
+ /**
1009
+ * Re-detects hover state immediately after refreshing items
1010
+ * Checks current mouse position and sets hover for any item under cursor
1011
+ *
1012
+ * @param {Array} items - The current items array
1013
+ * @param {string} layer - Input layer (default: 'gui')
1014
+ */
1015
+ reDetectHoverAfterRefresh(items, layer = "gui") {
1016
+ const pointer = this.input.getPointerPosition();
1017
+
1018
+ // Check if mouse is over the list area
1019
+ const isOverListArea =
1020
+ pointer.x >= this.listArea.x &&
1021
+ pointer.x <= this.listArea.x + this.listArea.width &&
1022
+ pointer.y >= this.listArea.y &&
1023
+ pointer.y <= this.listArea.y + this.listArea.height;
1024
+
1025
+ if (isOverListArea) {
1026
+ // Find which item (if any) is under the mouse
1027
+ for (let i = 0; i < items.length; i++) {
1028
+ const itemBounds = this.getItemBounds(i);
1029
+
1030
+ if (
1031
+ pointer.x >= itemBounds.x &&
1032
+ pointer.x <= itemBounds.x + itemBounds.width &&
1033
+ pointer.y >= itemBounds.y &&
1034
+ pointer.y <= itemBounds.y + itemBounds.height
1035
+ ) {
1036
+ // Found the item under mouse - set it as hovered
1037
+ const itemId = this.generateItemId(items[i], i);
1038
+ const element = this.input.rawState.elements[layer].get(itemId);
1039
+ if (element) {
1040
+ element.isHovered = true;
1041
+ element.hoverTimestamp = performance.now();
1042
+ }
1043
+ return; // Stop after finding the first (topmost) item
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Clears all item input registrations from the input system
1051
+ * This prevents multiple items from being selected when scrolling
1052
+ *
1053
+ * @param {string} layer - Input layer to clear (default: 'gui')
1054
+ */
1055
+ clearAllItemInputs(layer = "gui") {
1056
+ // Remove all currently registered items from input system
1057
+ this.registeredItems.forEach((itemId) => {
1058
+ this.input.removeElement(itemId, layer);
1059
+ });
1060
+ this.registeredItems.clear();
1061
+ }
1062
+
1063
+ /**
1064
+ * Draws the scrollable content area background fill only
1065
+ *
1066
+ * Renders the background fill for the scrollable content area.
1067
+ * Border is drawn separately to allow layering over items.
1068
+ *
1069
+ * @private
1070
+ */
1071
+ drawScrollableBackgroundFill() {
1072
+ if (!this.drawBackground) return;
1073
+
1074
+ const { x, y, width, height } = this.listArea;
1075
+
1076
+ // Draw background fill
1077
+ if (this.backgroundConfig.fillColor) {
1078
+ this.ctx.fillStyle = this.backgroundConfig.fillColor;
1079
+ if (this.backgroundConfig.cornerRadius > 0) {
1080
+ this.drawRoundedRect(x, y, width, height, this.backgroundConfig.cornerRadius);
1081
+ } else {
1082
+ this.ctx.fillRect(x, y, width, height);
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Draws the scrollable content area border only
1089
+ *
1090
+ * Renders the border for the scrollable content area.
1091
+ * Called after items are drawn to ensure border appears on top.
1092
+ *
1093
+ * @private
1094
+ */
1095
+ drawScrollableBorder() {
1096
+ if (!this.drawBackground) return;
1097
+
1098
+ const { x, y, width, height } = this.listArea;
1099
+
1100
+ // Draw border
1101
+ if (this.backgroundConfig.borderColor && this.backgroundConfig.borderWidth > 0) {
1102
+ this.ctx.strokeStyle = this.backgroundConfig.borderColor;
1103
+ this.ctx.lineWidth = this.backgroundConfig.borderWidth;
1104
+ if (this.backgroundConfig.cornerRadius > 0) {
1105
+ this.drawRoundedRectStroke(x, y, width, height, this.backgroundConfig.cornerRadius);
1106
+ } else {
1107
+ this.ctx.strokeRect(x, y, width, height);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ /**
1113
+ * Draws a rounded rectangle filled shape
1114
+ * @private
1115
+ */
1116
+ drawRoundedRect(x, y, width, height, radius) {
1117
+ this.ctx.beginPath();
1118
+ this.ctx.moveTo(x + radius, y);
1119
+ this.ctx.lineTo(x + width - radius, y);
1120
+ this.ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
1121
+ this.ctx.lineTo(x + width, y + height - radius);
1122
+ this.ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
1123
+ this.ctx.lineTo(x + radius, y + height);
1124
+ this.ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
1125
+ this.ctx.lineTo(x, y + radius);
1126
+ this.ctx.quadraticCurveTo(x, y, x + radius, y);
1127
+ this.ctx.closePath();
1128
+ this.ctx.fill();
1129
+ }
1130
+
1131
+ /**
1132
+ * Draws a rounded rectangle outline
1133
+ * @private
1134
+ */
1135
+ drawRoundedRectStroke(x, y, width, height, radius) {
1136
+ this.ctx.beginPath();
1137
+ this.ctx.moveTo(x + radius, y);
1138
+ this.ctx.lineTo(x + width - radius, y);
1139
+ this.ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
1140
+ this.ctx.lineTo(x + width, y + height - radius);
1141
+ this.ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
1142
+ this.ctx.lineTo(x + radius, y + height);
1143
+ this.ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
1144
+ this.ctx.lineTo(x, y + radius);
1145
+ this.ctx.quadraticCurveTo(x, y, x + radius, y);
1146
+ this.ctx.closePath();
1147
+ this.ctx.stroke();
1148
+ }
1149
+
1150
+ /**
1151
+ * Draws only the scrollbar elements (without background)
1152
+ *
1153
+ * Renders only the scrollbar UI components:
1154
+ * - Scrollbar track (background area)
1155
+ * - Scrollbar thumb (draggable handle)
1156
+ * - Up/Down arrow buttons
1157
+ *
1158
+ * Only draws the scrollbar if there's content to scroll (maxScrollOffset > 0).
1159
+ *
1160
+ * @private
1161
+ */
1162
+ drawScrollbarElements() {
1163
+ // Only show scrollbar if there's actually content to scroll
1164
+ if (this.maxScrollOffset > 0) {
1165
+ this.drawScrollbarTrack();
1166
+ this.drawScrollButtons();
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Draws the complete scrollbar interface with automatic clipping
1172
+ *
1173
+ * Enhanced drawing method that handles clipping automatically when enabled.
1174
+ * Provide a renderItem function to draw items without manual clipping management.
1175
+ *
1176
+ * @param {Array} items - Array of items to draw
1177
+ * @param {Function} renderItem - Function to render each item: (item, index, y) => void
1178
+ * @param {Object} options - Additional options
1179
+ * @param {Function} options.renderHeader - Optional header rendering function
1180
+ *
1181
+ * @example
1182
+ * this.roomScroller = new ActionScrollableArea({
1183
+ * enableClipping: true,
1184
+ * clipBounds: { x: 250, y: 330, width: 300, height: 240 }
1185
+ * });
1186
+ *
1187
+ * this.roomScroller.draw(rooms, (room, index, y) => {
1188
+ * this.guiCtx.fillStyle = isHovered ? '#0099dd' : '#007acc';
1189
+ * this.guiCtx.fillRect(250, y, 300, 25);
1190
+ * this.guiCtx.fillText(room.name, 400, y + 17);
1191
+ * }, {
1192
+ * renderHeader: () => {
1193
+ * this.guiCtx.fillText('Available Rooms:', 400, 320);
1194
+ * }
1195
+ * });
1196
+ */
1197
+ draw(items, renderItem, options = {}) {
1198
+ // Draw scrollbar elements first (never clipped)
1199
+ this.drawScrollbarElements();
1200
+
1201
+ // Apply clipping if enabled
1202
+ if (this.enableClipping && this.clipBounds) {
1203
+ this.ctx.save();
1204
+ this.ctx.beginPath();
1205
+ this.ctx.rect(this.clipBounds.x, this.clipBounds.y, this.clipBounds.width, this.clipBounds.height);
1206
+ this.ctx.clip();
1207
+ }
1208
+
1209
+ // Draw background (fill only, no border)
1210
+ this.drawScrollableBackgroundFill();
1211
+
1212
+ // Draw header if provided
1213
+ if (options.renderHeader) {
1214
+ options.renderHeader();
1215
+ }
1216
+
1217
+ // Draw all visible items with automatic clipping
1218
+ if (items && renderItem) {
1219
+ items.forEach((item, index) => {
1220
+ if (this.isItemVisible(index)) {
1221
+ const y = this.getItemDrawY(index);
1222
+ renderItem(item, index, y);
1223
+ }
1224
+ });
1225
+ }
1226
+
1227
+ // Restore clipping context
1228
+ if (this.enableClipping && this.clipBounds) {
1229
+ this.ctx.restore();
1230
+ }
1231
+
1232
+ // Draw border on top (after clipping restored)
1233
+ this.drawScrollableBorder();
1234
+ }
1235
+
1236
+ /**
1237
+ * Call this after drawing items to restore clipping context
1238
+ * Only needed if enableClipping is true and using legacy draw() method
1239
+ */
1240
+ endClipping() {
1241
+ if (this.enableClipping && this.clipBounds) {
1242
+ this.ctx.restore();
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Draws the scrollbar track and thumb with visual feedback
1248
+ *
1249
+ * Renders the scrollbar track (background) and thumb (draggable handle)
1250
+ * with sophisticated visual states:
1251
+ * - Track changes opacity when hovered for user feedback
1252
+ * - Thumb changes color and opacity based on interaction state
1253
+ * - Thumb shows different appearance when being dragged vs hovered vs idle
1254
+ * - All visual states provide immediate feedback about scrollbar state
1255
+ *
1256
+ * Color scheme:
1257
+ * - Track: Semi-transparent dark background
1258
+ * - Thumb: Blue with alpha transparency
1259
+ * - Hover: Increased brightness and opacity
1260
+ * - Drag: Maximum brightness and opacity with border highlight
1261
+ *
1262
+ * @private
1263
+ * @example
1264
+ * // Idle state: thumb is subtle blue
1265
+ * this.isDragging = false;
1266
+ * drawScrollbarTrack(); // Thumb: rgba(52, 152, 219, 0.3)
1267
+ *
1268
+ * // Hover state: thumb brightens
1269
+ * this.scrollThumb.hovered = true;
1270
+ * drawScrollbarTrack(); // Thumb: rgba(52, 152, 219, 0.6)
1271
+ *
1272
+ * // Drag state: thumb is most prominent
1273
+ * this.isDragging = true;
1274
+ * drawScrollbarTrack(); // Thumb: rgba(52, 152, 219, 0.8) with blue border
1275
+ */
1276
+ drawScrollbarTrack() {
1277
+ const scrollbarX = this.scrollArea.x;
1278
+ const scrollbarWidth = 20;
1279
+ const scrollAreaHeight = this.scrollArea.trackHeight;
1280
+
1281
+ // Check if mouse is over scrollbar track for visual feedback
1282
+ const mousePos = this.input.getPointerPosition();
1283
+ const isOverTrack =
1284
+ mousePos.x >= scrollbarX &&
1285
+ mousePos.x <= scrollbarX + scrollbarWidth &&
1286
+ mousePos.y >= this.scrollArea.thumbStartY &&
1287
+ mousePos.y <= this.scrollArea.y + this.scrollArea.trackHeight;
1288
+
1289
+ // Dark track background (using configurable colors)
1290
+ this.ctx.fillStyle = isOverTrack ? this.colors.track.hover : this.colors.track.normal;
1291
+ this.ctx.fillRect(scrollbarX, this.scrollArea.y, scrollbarWidth, scrollAreaHeight);
1292
+
1293
+ // Draw scrollbar thumb (using configurable colors)
1294
+ const thumbColor = this.scrollThumb.hovered
1295
+ ? this.colors.thumb.hover
1296
+ : this.isDragging
1297
+ ? this.colors.thumb.drag
1298
+ : this.colors.thumb.normal;
1299
+
1300
+ this.ctx.fillStyle = thumbColor;
1301
+ this.ctx.fillRect(this.scrollThumb.x, this.scrollThumb.y, this.scrollThumb.width, this.scrollThumb.height);
1302
+
1303
+ // Draw thumb border (using configurable colors)
1304
+ this.ctx.strokeStyle = this.isDragging ? this.colors.thumbBorder.drag : this.colors.thumbBorder.normal;
1305
+ this.ctx.lineWidth = 2;
1306
+ this.ctx.strokeRect(this.scrollThumb.x, this.scrollThumb.y, this.scrollThumb.width, this.scrollThumb.height);
1307
+ }
1308
+
1309
+ /**
1310
+ * Draws both the up and down scroll buttons
1311
+ *
1312
+ * Convenience method that renders both arrow buttons at the top and
1313
+ * bottom of the scrollbar track. Each button provides incremental
1314
+ * scrolling when clicked.
1315
+ *
1316
+ * @private
1317
+ * @example
1318
+ * // Draws up button (▲) at top of track
1319
+ * // Draws down button (▼) at bottom of track
1320
+ * drawScrollButtons();
1321
+ */
1322
+ drawScrollButtons() {
1323
+ this.drawScrollButton(this.scrollUpButton);
1324
+ this.drawScrollButton(this.scrollDownButton);
1325
+ }
1326
+
1327
+ /**
1328
+ * Draws a single scroll button with hover effects and proper styling
1329
+ *
1330
+ * Renders an individual scroll button (up or down arrow) with:
1331
+ * - Semi-transparent background that brightens on hover
1332
+ * - Border that highlights when hovered
1333
+ * - Centered text (▲ or ▼) that changes color on interaction
1334
+ * - Consistent visual feedback matching the scrollbar theme
1335
+ *
1336
+ * The button integrates with the input system for click detection
1337
+ * and hover state management.
1338
+ *
1339
+ * @param {Object} button - Button configuration object
1340
+ * @param {number} button.x - X coordinate of the button
1341
+ * @param {number} button.y - Y coordinate of the button
1342
+ * @param {number} button.width - Width of the button
1343
+ * @param {number} button.height - Height of the button
1344
+ * @param {string} button.text - Button text (▲ or ▼)
1345
+ * @param {boolean} button.hovered - Current hover state
1346
+ *
1347
+ * @private
1348
+ * @example
1349
+ * // Draw up button with hover effect
1350
+ * const upButton = {
1351
+ * x: 860, y: 90, width: 20, height: 20,
1352
+ * text: "▲", hovered: true
1353
+ * };
1354
+ * drawScrollButton(upButton);
1355
+ * // Renders bright button with "#3498DB" text color
1356
+ */
1357
+ drawScrollButton(button) {
1358
+ this.ctx.save();
1359
+
1360
+ const cornerX = button.x;
1361
+ const cornerY = button.y;
1362
+
1363
+ // Button background (using configurable colors)
1364
+ this.ctx.fillStyle = button.hovered ? this.colors.button.hover : this.colors.button.normal;
1365
+ this.ctx.fillRect(cornerX, cornerY, button.width, button.height);
1366
+
1367
+ // Button border (using configurable colors)
1368
+ this.ctx.strokeStyle = button.hovered ? this.colors.buttonText.hover : this.colors.buttonText.normal;
1369
+ this.ctx.lineWidth = 1;
1370
+ this.ctx.strokeRect(cornerX, cornerY, button.width, button.height);
1371
+
1372
+ // Button text (using configurable colors)
1373
+ this.ctx.fillStyle = button.hovered ? this.colors.buttonText.hover : this.colors.buttonText.normal;
1374
+ this.ctx.font = "bold 14px Arial";
1375
+ this.ctx.textAlign = "center";
1376
+ this.ctx.textBaseline = "middle";
1377
+ this.ctx.fillText(button.text, button.x + button.width / 2, button.y + button.height / 2 + 1);
1378
+
1379
+ this.ctx.restore();
1380
+ }
1381
+
1382
+ /**
1383
+ * Destroys the scrollable area and cleans up all resources
1384
+ *
1385
+ * Properly cleans up the component by:
1386
+ * 1. Removing all input element registrations from the input system
1387
+ * 2. Removing mouse wheel event listeners from canvas and window
1388
+ * 3. Preventing memory leaks and input conflicts
1389
+ *
1390
+ * Always call this method when removing a scrollable area from your game
1391
+ * to ensure proper cleanup and prevent lingering event handlers.
1392
+ *
1393
+ * @example
1394
+ * // When removing a UI screen or component
1395
+ * destroy() {
1396
+ * this.inventoryScroller.destroy();
1397
+ * this.inventoryScroller = null;
1398
+ * }
1399
+ */
1400
+ destroy() {
1401
+ this.input.removeElement(this.elementIds.scrollUp, "gui");
1402
+ this.input.removeElement(this.elementIds.scrollDown, "gui");
1403
+ this.input.removeElement(this.elementIds.scrollbarTrack, "gui");
1404
+ }
1405
+ }