@guinetik/gcanvas 1.0.4 → 1.0.5

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 (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,1195 @@
1
+ /**
2
+ * 3D Tetris Game
3
+ *
4
+ * A 3D falling block puzzle game using Cube3D.
5
+ * Pieces fall into a 6x6x16 well with camera rotation.
6
+ */
7
+
8
+ import {
9
+ Game,
10
+ Scene,
11
+ Text,
12
+ FPSCounter,
13
+ Keys,
14
+ Position,
15
+ Button,
16
+ Tweenetik,
17
+ Easing,
18
+ } from "/gcanvas.es.min.js";
19
+ import { Camera3D } from "/gcanvas.es.min.js";
20
+ import { CONFIG, SHAPES } from "./config.js";
21
+ import { getRandomPiece, resetPieceBag } from "./tetrominos.js";
22
+ import { Grid } from "./grid.js";
23
+ import { WellRenderer, BlockRenderer, NextPieceRenderer } from "./renderer.js";
24
+
25
+ /**
26
+ * Game states
27
+ */
28
+ const GameState = {
29
+ READY: "ready",
30
+ PLAYING: "playing",
31
+ PAUSED: "paused",
32
+ GAME_OVER: "gameover",
33
+ LINE_CLEAR: "lineclear",
34
+ };
35
+
36
+ /**
37
+ * Tetris3DGame - Main game class
38
+ */
39
+ class Tetris3DGame extends Game {
40
+ constructor(canvas) {
41
+ super(canvas);
42
+ this.backgroundColor = CONFIG.visual.backgroundColor;
43
+ this.enableFluidSize();
44
+ }
45
+
46
+ init() {
47
+ super.init();
48
+
49
+ // Game state
50
+ this.gameState = GameState.READY;
51
+ this.score = 0;
52
+ this.level = 1;
53
+ this.linesCleared = 0;
54
+
55
+ // Calculate dynamic sizes based on screen
56
+ this._updateSizes();
57
+
58
+ // Create camera with mouse controls
59
+ this.camera = new Camera3D({
60
+ perspective: this._getDynamicPerspective(),
61
+ rotationX: CONFIG.camera.rotationX,
62
+ rotationY: CONFIG.camera.rotationY,
63
+ inertia: CONFIG.camera.inertia,
64
+ friction: CONFIG.camera.friction,
65
+ });
66
+ this.camera.enableMouseControl(this.canvas);
67
+
68
+ // Preview camera (fixed angle)
69
+ this.previewCamera = new Camera3D({
70
+ perspective: 400,
71
+ rotationX: 0.4,
72
+ rotationY: -0.5,
73
+ });
74
+
75
+ // Game grid
76
+ this.grid = new Grid();
77
+
78
+ // Renderers with dynamic sizes
79
+ this.wellRenderer = new WellRenderer(this.camera, this.cubeSize, this.cubeGap);
80
+ this.blockRenderer = new BlockRenderer(this.camera, this.cubeSize, this.cubeGap);
81
+ this.nextPieceRenderer = new NextPieceRenderer(this.previewCamera, this.cubeSize);
82
+
83
+ // Current and next piece
84
+ this.currentPiece = null;
85
+ this.nextPiece = null;
86
+ this.nextPieceType = null;
87
+
88
+ // Timing
89
+ this.fallTimer = 0;
90
+ this.lockTimer = 0;
91
+ this.isLocking = false;
92
+
93
+ // Input state
94
+ this.moveRepeatTimer = 0;
95
+ this.moveRepeatKey = null;
96
+ this.softDropping = false;
97
+
98
+ // Camera preset cycling
99
+ this.cameraPresetIndex = 0;
100
+
101
+ // Hint ghost (optimal position preview)
102
+ this.hintPositions = null;
103
+ this.hintRotations = 0;
104
+
105
+ // Auto-play mode
106
+ this.autoPlayEnabled = false;
107
+ this.autoPlayTimer = 0;
108
+ this.autoPlayDelay = 0.3; // Seconds between auto moves
109
+
110
+ // Line clear animation
111
+ this.lineClearTimer = 0;
112
+ this.clearedLayers = [];
113
+
114
+ // Setup input handlers
115
+ this._setupInput();
116
+
117
+ // Create UI
118
+ this._createUI();
119
+ }
120
+
121
+ /**
122
+ * Setup keyboard input handlers
123
+ * @private
124
+ */
125
+ _setupInput() {
126
+ // Movement - Arrow keys
127
+ this.events.on(Keys.LEFT, () => this._handleMove(-1, 0));
128
+ this.events.on(Keys.RIGHT, () => this._handleMove(1, 0));
129
+ this.events.on(Keys.UP, () => this._handleMove(0, -1));
130
+ this.events.on(Keys.DOWN, () => this._handleMove(0, 1));
131
+
132
+ // Movement - WASD (sacred!)
133
+ this.events.on(Keys.A, () => this._handleMove(-1, 0)); // Left
134
+ this.events.on(Keys.D, () => this._handleMove(1, 0)); // Right
135
+ this.events.on(Keys.W, () => this._handleMove(0, -1)); // Forward
136
+ this.events.on(Keys.S, () => this._handleMove(0, 1)); // Back
137
+
138
+ // Y-axis rotation - Q/E (horizontal)
139
+ this.events.on(Keys.Q, () => this._handleRotate("y", -1)); // CCW
140
+ this.events.on(Keys.E, () => this._handleRotate("y", 1)); // CW
141
+
142
+ // X-axis rotation - R/F (pitch forward/back)
143
+ this.events.on(Keys.R, () => this._handleRotate("x", 1));
144
+ this.events.on(Keys.F, () => this._handleRotate("x", -1));
145
+
146
+ // Z-axis rotation - Z/C (roll left/right)
147
+ this.events.on(Keys.Z, () => this._handleRotate("z", 1));
148
+ this.events.on(Keys.C, () => this._handleRotate("z", -1));
149
+
150
+ // Hard drop
151
+ this.events.on(Keys.SPACE, () => this._handleHardDrop());
152
+
153
+ // Start/restart on Enter
154
+ this.events.on(Keys.ENTER, () => this._handleStart());
155
+
156
+ // Pause on Escape
157
+ this.events.on(Keys.ESC, () => this._handlePause());
158
+ }
159
+
160
+ /**
161
+ * Create UI elements
162
+ * @private
163
+ */
164
+ _createUI() {
165
+ // Score display
166
+ this.scoreText = new Text(this, "SCORE: 0", {
167
+ font: "bold 18px monospace",
168
+ color: CONFIG.visual.wellColor,
169
+ anchor: Position.TOP_LEFT,
170
+ anchorMargin: 20,
171
+ });
172
+ this.pipeline.add(this.scoreText);
173
+
174
+ // Level display
175
+ this.levelText = new Text(this, "LEVEL: 1", {
176
+ font: "16px monospace",
177
+ color: CONFIG.visual.wellColor,
178
+ anchor: Position.TOP_LEFT,
179
+ anchorMargin: 20,
180
+ anchorOffsetY: 30,
181
+ });
182
+ this.pipeline.add(this.levelText);
183
+
184
+ // Lines display
185
+ this.linesText = new Text(this, "LINES: 0", {
186
+ font: "16px monospace",
187
+ color: CONFIG.visual.wellColor,
188
+ anchor: Position.TOP_LEFT,
189
+ anchorMargin: 20,
190
+ anchorOffsetY: 55,
191
+ });
192
+ this.pipeline.add(this.linesText);
193
+
194
+ // Next piece label
195
+ this.nextLabel = new Text(this, "NEXT:", {
196
+ font: "bold 14px monospace",
197
+ color: CONFIG.visual.wellColor,
198
+ anchor: Position.TOP_RIGHT,
199
+ anchorMargin: 20,
200
+ });
201
+ this.pipeline.add(this.nextLabel);
202
+
203
+ // State message (center) - large and prominent
204
+ this.stateMessage = new Text(this, "", {
205
+ font: "bold 36px monospace",
206
+ color: "#fff",
207
+ anchor: Position.CENTER,
208
+ anchorOffsetY: -20,
209
+ });
210
+ this.pipeline.add(this.stateMessage);
211
+
212
+ // Sub-message
213
+ this.subMessage = new Text(this, "", {
214
+ font: "18px monospace",
215
+ color: CONFIG.visual.wellColor,
216
+ anchor: Position.CENTER,
217
+ anchorOffsetY: 25,
218
+ });
219
+ this.pipeline.add(this.subMessage);
220
+
221
+ // Score display on game over (hidden initially)
222
+ this.gameOverScore = new Text(this, "", {
223
+ font: "bold 24px monospace",
224
+ color: "#FFD700",
225
+ anchor: Position.CENTER,
226
+ anchorOffsetY: 60,
227
+ });
228
+ this.pipeline.add(this.gameOverScore);
229
+
230
+ // Controls help
231
+ this.controlsText = new Text(
232
+ this,
233
+ "WASD: Move | Q/E: RotY | R/F: RotX | Z/C: RotZ | SPACE: Drop",
234
+ {
235
+ font: "12px monospace",
236
+ color: "#666",
237
+ anchor: Position.BOTTOM_CENTER,
238
+ anchorMargin: 15,
239
+ }
240
+ );
241
+ this.pipeline.add(this.controlsText);
242
+
243
+ // Camera view cycle button
244
+ const presets = CONFIG.camera.presets;
245
+ this.cameraButton = new Button(this, {
246
+ text: `View: ${presets[0].name}`,
247
+ width: 120,
248
+ height: 32,
249
+ font: "12px monospace",
250
+ anchor: Position.BOTTOM_LEFT,
251
+ anchorMargin: 15,
252
+ onClick: () => this._cycleCamera(),
253
+ });
254
+ this.pipeline.add(this.cameraButton);
255
+
256
+ // Restart button (next to view button)
257
+ this.restartButton = new Button(this, {
258
+ text: "Restart",
259
+ width: 90,
260
+ height: 32,
261
+ font: "12px monospace",
262
+ anchor: Position.BOTTOM_LEFT,
263
+ anchorMargin: 15,
264
+ anchorOffsetX: 130,
265
+ onClick: () => this._startGame(),
266
+ });
267
+ this.pipeline.add(this.restartButton);
268
+
269
+ // Hint button - shows ghost of optimal position
270
+ this.hintButton = new Button(this, {
271
+ text: "Hint",
272
+ width: 60,
273
+ height: 32,
274
+ font: "12px monospace",
275
+ anchor: Position.BOTTOM_LEFT,
276
+ anchorMargin: 15,
277
+ anchorOffsetX: 230,
278
+ onClick: () => this._showHint(),
279
+ });
280
+ this.pipeline.add(this.hintButton);
281
+
282
+ // Auto-Play button - AI plays automatically
283
+ this.autoPlayButton = new Button(this, {
284
+ text: "Auto",
285
+ width: 60,
286
+ height: 32,
287
+ font: "12px monospace",
288
+ anchor: Position.BOTTOM_LEFT,
289
+ anchorMargin: 15,
290
+ anchorOffsetX: 300,
291
+ onClick: () => this._toggleAutoPlay(),
292
+ });
293
+ this.pipeline.add(this.autoPlayButton);
294
+
295
+ // FPS counter
296
+ this.pipeline.add(
297
+ new FPSCounter(this, {
298
+ anchor: Position.BOTTOM_RIGHT,
299
+ })
300
+ );
301
+
302
+ // Show start message
303
+ this._showMessage("3D TETRIS", "Press ENTER to start");
304
+ }
305
+
306
+ /**
307
+ * Show hint - display ghost of optimal position
308
+ * @private
309
+ */
310
+ _showHint() {
311
+ if (this.gameState !== GameState.PLAYING || !this.currentPiece) return;
312
+
313
+ const bestMove = this._findBestMove();
314
+ if (bestMove) {
315
+ // Calculate hint positions (where the piece would go)
316
+ const tempPiece = this.currentPiece.clone();
317
+
318
+ // Apply rotations to temp piece (Y, X, Z order)
319
+ for (let i = 0; i < bestMove.rotY; i++) tempPiece.rotate("y", 1);
320
+ for (let i = 0; i < bestMove.rotX; i++) tempPiece.rotate("x", 1);
321
+ for (let i = 0; i < bestMove.rotZ; i++) tempPiece.rotate("z", 1);
322
+
323
+ tempPiece.x = bestMove.x;
324
+ tempPiece.z = bestMove.z;
325
+
326
+ // Get landing Y for the optimal position
327
+ const landingY = this.grid.calculateLandingY(tempPiece);
328
+ tempPiece.y = landingY;
329
+
330
+ // Store hint positions for rendering
331
+ this.hintPositions = tempPiece.getWorldPositions();
332
+
333
+ // Update renderers to show hint
334
+ this._updateRenderers();
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Clear the hint ghost
340
+ * @private
341
+ */
342
+ _clearHint() {
343
+ this.hintPositions = null;
344
+ this.hintRotations = 0;
345
+ }
346
+
347
+ /**
348
+ * Toggle auto-play mode
349
+ * @private
350
+ */
351
+ _toggleAutoPlay() {
352
+ this.autoPlayEnabled = !this.autoPlayEnabled;
353
+ this.autoPlayTimer = 0;
354
+
355
+ // Update button text
356
+ this.autoPlayButton.text = this.autoPlayEnabled ? "Stop" : "Auto";
357
+
358
+ // Start game if not playing
359
+ if (this.autoPlayEnabled && this.gameState !== GameState.PLAYING) {
360
+ this._startGame();
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Execute auto-play move
366
+ * @private
367
+ */
368
+ _autoPlayMove() {
369
+ if (!this.currentPiece) return;
370
+
371
+ const bestMove = this._findBestMove();
372
+ if (bestMove) {
373
+ // Apply rotations (Y, X, Z order)
374
+ for (let i = 0; i < bestMove.rotY; i++) this.currentPiece.rotate("y", 1);
375
+ for (let i = 0; i < bestMove.rotX; i++) this.currentPiece.rotate("x", 1);
376
+ for (let i = 0; i < bestMove.rotZ; i++) this.currentPiece.rotate("z", 1);
377
+
378
+ // Move to best position
379
+ this.currentPiece.x = bestMove.x;
380
+ this.currentPiece.z = bestMove.z;
381
+
382
+ // Hard drop
383
+ const landingY = this.grid.calculateLandingY(this.currentPiece);
384
+ this.currentPiece.y = landingY;
385
+
386
+ // Lock immediately
387
+ this._lockPiece();
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Find the best position for the current piece
393
+ * @returns {{x: number, z: number, rotY: number, rotX: number, rotZ: number, score: number}|null}
394
+ * @private
395
+ */
396
+ _findBestMove() {
397
+ if (!this.currentPiece) return null;
398
+
399
+ const { width, depth } = CONFIG.grid;
400
+ let bestMove = null;
401
+ let bestScore = -Infinity;
402
+
403
+ // Save original state
404
+ const originalX = this.currentPiece.x;
405
+ const originalZ = this.currentPiece.z;
406
+ const originalVoxels = this.currentPiece.voxels.map((v) => ({ ...v }));
407
+
408
+ // Try rotation combinations (Y: 4, X: 2, Z: 2 = 16 combos for efficiency)
409
+ for (let rotY = 0; rotY < 4; rotY++) {
410
+ for (let rotX = 0; rotX < 2; rotX++) {
411
+ for (let rotZ = 0; rotZ < 2; rotZ++) {
412
+ // Reset to original voxels
413
+ this.currentPiece.voxels = originalVoxels.map((v) => ({ ...v }));
414
+
415
+ // Apply rotations
416
+ for (let i = 0; i < rotY; i++) this.currentPiece.rotate("y", 1);
417
+ for (let i = 0; i < rotX; i++) this.currentPiece.rotate("x", 1);
418
+ for (let i = 0; i < rotZ; i++) this.currentPiece.rotate("z", 1);
419
+
420
+ const bounds = this.currentPiece.getBounds();
421
+
422
+ // Try all positions
423
+ for (let x = 0; x <= width - bounds.width; x++) {
424
+ for (let z = 0; z <= depth - bounds.depth; z++) {
425
+ this.currentPiece.x = x;
426
+ this.currentPiece.z = z;
427
+
428
+ // Check if position is valid
429
+ const positions = this.currentPiece.getWorldPositions();
430
+ if (!this.grid.canPlace(positions)) continue;
431
+
432
+ // Calculate landing Y and score
433
+ const landingY = this.grid.calculateLandingY(this.currentPiece);
434
+ const score = this._evaluatePosition(x, z, landingY, positions);
435
+
436
+ if (score > bestScore) {
437
+ bestScore = score;
438
+ bestMove = { x, z, rotY, rotX, rotZ, score };
439
+ }
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ // Restore original state
447
+ this.currentPiece.x = originalX;
448
+ this.currentPiece.z = originalZ;
449
+ this.currentPiece.voxels = originalVoxels;
450
+
451
+ return bestMove;
452
+ }
453
+
454
+ /**
455
+ * Evaluate how good a position is
456
+ * @private
457
+ */
458
+ _evaluatePosition(x, z, landingY, positions) {
459
+ let score = 0;
460
+
461
+ // Simulate placing the piece
462
+ const tempPositions = positions.map(p => ({ ...p, y: landingY }));
463
+
464
+ // Check how many lines would be cleared
465
+ const linesCleared = this._simulateLineClears(tempPositions);
466
+ score += linesCleared * 1000; // High priority for line clears
467
+
468
+ // Prefer lower positions (higher Y = lower in well)
469
+ score += landingY * 10;
470
+
471
+ // Prefer center positions slightly
472
+ const centerX = CONFIG.grid.width / 2;
473
+ const centerZ = CONFIG.grid.depth / 2;
474
+ const distFromCenter = Math.abs(x - centerX) + Math.abs(z - centerZ);
475
+ score -= distFromCenter * 2;
476
+
477
+ // Penalize creating holes
478
+ const holes = this._countHolesCreated(tempPositions);
479
+ score -= holes * 50;
480
+
481
+ return score;
482
+ }
483
+
484
+ /**
485
+ * Simulate how many lines would be cleared
486
+ * @private
487
+ */
488
+ _simulateLineClears(positions) {
489
+ const { width, depth, height } = CONFIG.grid;
490
+ let linesCleared = 0;
491
+
492
+ // Get all Y levels affected
493
+ const yLevels = [...new Set(positions.map(p => p.y))];
494
+
495
+ for (const y of yLevels) {
496
+ if (y < 0 || y >= height) continue;
497
+
498
+ // Count filled cells at this level (existing + new)
499
+ let filledCount = 0;
500
+ for (let gx = 0; gx < width; gx++) {
501
+ for (let gz = 0; gz < depth; gz++) {
502
+ const isNewPiece = positions.some(p => p.x === gx && p.y === y && p.z === gz);
503
+ const isExisting = this.grid.isOccupied(gx, y, gz);
504
+ if (isNewPiece || isExisting) {
505
+ filledCount++;
506
+ }
507
+ }
508
+ }
509
+
510
+ if (filledCount === width * depth) {
511
+ linesCleared++;
512
+ }
513
+ }
514
+
515
+ return linesCleared;
516
+ }
517
+
518
+ /**
519
+ * Count holes that would be created
520
+ * @private
521
+ */
522
+ _countHolesCreated(positions) {
523
+ let holes = 0;
524
+ const { width, depth, height } = CONFIG.grid;
525
+
526
+ for (const pos of positions) {
527
+ // Check cell below each piece position
528
+ const belowY = pos.y + 1;
529
+ if (belowY < height) {
530
+ if (!this.grid.isOccupied(pos.x, belowY, pos.z)) {
531
+ // Check if there's something above that would trap this
532
+ let hasBlockAbove = false;
533
+ for (let y = pos.y - 1; y >= 0; y--) {
534
+ if (this.grid.isOccupied(pos.x, y, pos.z) ||
535
+ positions.some(p => p.x === pos.x && p.y === y && p.z === pos.z)) {
536
+ hasBlockAbove = true;
537
+ break;
538
+ }
539
+ }
540
+ if (!hasBlockAbove) holes++;
541
+ }
542
+ }
543
+ }
544
+
545
+ return holes;
546
+ }
547
+
548
+ /**
549
+ * Cycle through camera presets
550
+ * @private
551
+ */
552
+ _cycleCamera() {
553
+ const presets = CONFIG.camera.presets;
554
+ this.cameraPresetIndex = (this.cameraPresetIndex + 1) % presets.length;
555
+ const preset = presets[this.cameraPresetIndex];
556
+
557
+ // Apply preset rotation
558
+ this.camera.rotationX = preset.rotationX;
559
+ this.camera.rotationY = preset.rotationY;
560
+
561
+ // Reset velocity to stop any inertia
562
+ this.camera.velocityX = 0;
563
+ this.camera.velocityY = 0;
564
+
565
+ // Update button text
566
+ this.cameraButton.text = `View: ${preset.name}`;
567
+ }
568
+
569
+ /**
570
+ * Calculate dynamic sizes based on screen dimensions
571
+ * @private
572
+ */
573
+ _updateSizes() {
574
+ const minDim = Math.min(this.width, this.height);
575
+ this.cubeSize = minDim * CONFIG.visual.cubeSizeFraction;
576
+ this.cubeGap = minDim * CONFIG.visual.cubeGapFraction;
577
+ }
578
+
579
+ /**
580
+ * Calculate dynamic perspective based on screen size
581
+ * @private
582
+ */
583
+ _getDynamicPerspective() {
584
+ return Math.min(this.width, this.height) * 1.2;
585
+ }
586
+
587
+ /**
588
+ * Handle window resize
589
+ */
590
+ onResize() {
591
+ // Recalculate sizes
592
+ this._updateSizes();
593
+
594
+ // Update camera perspective
595
+ if (this.camera) {
596
+ this.camera.perspective = this._getDynamicPerspective();
597
+ }
598
+
599
+ // Update renderers with new sizes
600
+ if (this.wellRenderer) {
601
+ this.wellRenderer.updateSize(this.cubeSize, this.cubeGap);
602
+ }
603
+ if (this.blockRenderer) {
604
+ this.blockRenderer.updateSize(this.cubeSize, this.cubeGap);
605
+ // Rebuild cubes with new sizes
606
+ this._updateRenderers();
607
+ }
608
+ if (this.nextPieceRenderer) {
609
+ this.nextPieceRenderer.updateSize(this.cubeSize);
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Show a centered message
615
+ * @private
616
+ */
617
+ _showMessage(main, sub = "", score = "") {
618
+ this.stateMessage.text = main;
619
+ this.subMessage.text = sub;
620
+ this.gameOverScore.text = score;
621
+ }
622
+
623
+ /**
624
+ * Clear the centered message
625
+ * @private
626
+ */
627
+ _clearMessage() {
628
+ this.stateMessage.text = "";
629
+ this.subMessage.text = "";
630
+ this.gameOverScore.text = "";
631
+ }
632
+
633
+ /**
634
+ * Handle start/restart
635
+ * @private
636
+ */
637
+ _handleStart() {
638
+ if (this.gameState === GameState.READY || this.gameState === GameState.GAME_OVER) {
639
+ this._startGame();
640
+ } else if (this.gameState === GameState.PAUSED) {
641
+ this._resumeGame();
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Handle pause toggle
647
+ * @private
648
+ */
649
+ _handlePause() {
650
+ if (this.gameState === GameState.PLAYING) {
651
+ this._pauseGame();
652
+ } else if (this.gameState === GameState.PAUSED) {
653
+ this._resumeGame();
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Start a new game
659
+ * @private
660
+ */
661
+ _startGame() {
662
+ this.gameState = GameState.PLAYING;
663
+ this.score = 0;
664
+ this.level = 1;
665
+ this.linesCleared = 0;
666
+
667
+ // Reset grid and piece bag
668
+ this.grid.clear();
669
+ resetPieceBag();
670
+ this.nextPiece = null;
671
+
672
+ // Clear hint
673
+ this._clearHint();
674
+
675
+ // Spawn first piece
676
+ this._spawnPiece();
677
+
678
+ // Clear message
679
+ this._clearMessage();
680
+
681
+ // Update UI
682
+ this._updateUI();
683
+ }
684
+
685
+ /**
686
+ * Pause the game
687
+ * @private
688
+ */
689
+ _pauseGame() {
690
+ this.gameState = GameState.PAUSED;
691
+ this._showMessage("PAUSED", "Press ESC or ENTER to resume");
692
+ }
693
+
694
+ /**
695
+ * Resume the game
696
+ * @private
697
+ */
698
+ _resumeGame() {
699
+ this.gameState = GameState.PLAYING;
700
+ this._clearMessage();
701
+ }
702
+
703
+ /**
704
+ * End the game
705
+ * @private
706
+ */
707
+ _gameOver() {
708
+ this.gameState = GameState.GAME_OVER;
709
+
710
+ // Stop auto-play
711
+ if (this.autoPlayEnabled) {
712
+ this.autoPlayEnabled = false;
713
+ this.autoPlayButton.text = "Auto";
714
+ }
715
+
716
+ this._showMessage(
717
+ "GAME OVER",
718
+ "Press ENTER or click Restart",
719
+ `SCORE: ${this.score} | LEVEL: ${this.level} | LINES: ${this.linesCleared}`
720
+ );
721
+ }
722
+
723
+ /**
724
+ * Spawn a new piece
725
+ * @private
726
+ */
727
+ _spawnPiece() {
728
+ // Use queued next piece, or get first piece
729
+ if (this.nextPiece) {
730
+ this.currentPiece = this.nextPiece;
731
+ } else {
732
+ this.currentPiece = getRandomPiece();
733
+ }
734
+
735
+ // Get the NEXT piece from the bag (consumes it properly)
736
+ this.nextPiece = getRandomPiece();
737
+ this.nextPieceType = this.nextPiece.type;
738
+
739
+ // Update preview
740
+ const nextShape = SHAPES[this.nextPieceType];
741
+ this.nextPieceRenderer.update(
742
+ this.nextPieceType,
743
+ nextShape.color,
744
+ nextShape.matrix
745
+ );
746
+
747
+ // Reset timers
748
+ this.fallTimer = 0;
749
+ this.lockTimer = 0;
750
+ this.isLocking = false;
751
+
752
+ // Check if spawn position is blocked (game over)
753
+ const positions = this.currentPiece.getWorldPositions();
754
+ if (!this.grid.canPlace(positions)) {
755
+ this._gameOver();
756
+ }
757
+
758
+ // Update renderers
759
+ this._updateRenderers();
760
+ }
761
+
762
+ /**
763
+ * Handle movement input
764
+ * @param {number} dx - X direction
765
+ * @param {number} dz - Z direction
766
+ * @private
767
+ */
768
+ _handleMove(dx, dz) {
769
+ if (this.gameState !== GameState.PLAYING || !this.currentPiece) return;
770
+
771
+ this._clearHint(); // Clear hint when player moves
772
+ this._tryMove(dx, 0, dz);
773
+ }
774
+
775
+ /**
776
+ * Try to move the current piece
777
+ * @param {number} dx
778
+ * @param {number} dy
779
+ * @param {number} dz
780
+ * @returns {boolean} Success
781
+ * @private
782
+ */
783
+ _tryMove(dx, dy, dz) {
784
+ if (!this.currentPiece) return false;
785
+
786
+ const piece = this.currentPiece;
787
+
788
+ // Calculate new positions by offsetting current world positions
789
+ const newPositions = piece.voxels.map((v) => ({
790
+ x: piece.x + v.x + dx,
791
+ y: piece.y + v.y + dy,
792
+ z: piece.z + v.z + dz,
793
+ }));
794
+
795
+ // Check collision
796
+ if (this.grid.canPlace(newPositions)) {
797
+ piece.move(dx, dy, dz);
798
+ this._updateRenderers();
799
+
800
+ // Reset lock timer if we moved while locking
801
+ if (this.isLocking && (dx !== 0 || dz !== 0)) {
802
+ this.lockTimer = 0;
803
+ }
804
+
805
+ return true;
806
+ }
807
+
808
+ return false;
809
+ }
810
+
811
+ /**
812
+ * Handle rotation input
813
+ * @param {string} axis - 'x', 'y', or 'z'
814
+ * @param {number} direction - 1 for CW, -1 for CCW
815
+ * @private
816
+ */
817
+ _handleRotate(axis, direction) {
818
+ if (this.gameState !== GameState.PLAYING || !this.currentPiece) return;
819
+
820
+ this._clearHint(); // Clear hint when player rotates
821
+ this._tryRotate(axis, direction);
822
+ }
823
+
824
+ /**
825
+ * Try to rotate the current piece with wall kicks
826
+ * @param {string} axis - 'x', 'y', or 'z'
827
+ * @param {number} direction
828
+ * @returns {boolean} Success
829
+ * @private
830
+ */
831
+ _tryRotate(axis, direction) {
832
+ if (!this.currentPiece) return false;
833
+
834
+ const piece = this.currentPiece;
835
+
836
+ // Save original voxels for rollback
837
+ const originalVoxels = piece.voxels.map((v) => ({ ...v }));
838
+
839
+ // Try rotation
840
+ piece.rotate(axis, direction);
841
+
842
+ // Check if rotation is valid
843
+ const positions = piece.getWorldPositions();
844
+ if (this.grid.canPlace(positions)) {
845
+ this._updateRenderers();
846
+
847
+ // Reset lock timer
848
+ if (this.isLocking) {
849
+ this.lockTimer = 0;
850
+ }
851
+
852
+ return true;
853
+ }
854
+
855
+ // Try wall kicks based on axis
856
+ const kicks = this._getWallKicks(axis);
857
+
858
+ for (const kick of kicks) {
859
+ piece.x += kick.x;
860
+ piece.y += kick.y || 0;
861
+ piece.z += kick.z;
862
+
863
+ const kickPositions = piece.getWorldPositions();
864
+ if (this.grid.canPlace(kickPositions)) {
865
+ this._updateRenderers();
866
+
867
+ if (this.isLocking) {
868
+ this.lockTimer = 0;
869
+ }
870
+
871
+ return true;
872
+ }
873
+
874
+ // Undo kick
875
+ piece.x -= kick.x;
876
+ piece.y -= kick.y || 0;
877
+ piece.z -= kick.z;
878
+ }
879
+
880
+ // Rotation failed, restore original voxels
881
+ piece.voxels = originalVoxels;
882
+ return false;
883
+ }
884
+
885
+ /**
886
+ * Get wall kicks for a specific rotation axis
887
+ * @param {string} axis - 'x', 'y', or 'z'
888
+ * @returns {Array<{x: number, y: number, z: number}>}
889
+ * @private
890
+ */
891
+ _getWallKicks(axis) {
892
+ if (axis === "y") {
893
+ // Horizontal rotation - only X/Z kicks
894
+ return [
895
+ { x: 1, z: 0, y: 0 },
896
+ { x: -1, z: 0, y: 0 },
897
+ { x: 0, z: 1, y: 0 },
898
+ { x: 0, z: -1, y: 0 },
899
+ { x: 2, z: 0, y: 0 },
900
+ { x: -2, z: 0, y: 0 },
901
+ ];
902
+ } else {
903
+ // X and Z rotations may need vertical kicks
904
+ return [
905
+ { x: 1, z: 0, y: 0 },
906
+ { x: -1, z: 0, y: 0 },
907
+ { x: 0, z: 1, y: 0 },
908
+ { x: 0, z: -1, y: 0 },
909
+ { x: 0, z: 0, y: -1 }, // Kick up
910
+ { x: 0, z: 0, y: -2 }, // Kick up more
911
+ { x: 1, z: 0, y: -1 },
912
+ { x: -1, z: 0, y: -1 },
913
+ ];
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Handle hard drop
919
+ * @private
920
+ */
921
+ _handleHardDrop() {
922
+ if (this.gameState !== GameState.PLAYING || !this.currentPiece) return;
923
+
924
+ const piece = this.currentPiece;
925
+ const startY = piece.y;
926
+ const landingY = this.grid.calculateLandingY(piece);
927
+
928
+ // Move to landing position
929
+ piece.y = landingY;
930
+
931
+ // Add hard drop score
932
+ const dropDistance = landingY - startY;
933
+ this.score += dropDistance * CONFIG.scoring.hardDrop;
934
+
935
+ // Lock immediately
936
+ this._lockPiece();
937
+ }
938
+
939
+ /**
940
+ * Lock the current piece into the grid
941
+ * @private
942
+ */
943
+ _lockPiece() {
944
+ if (!this.currentPiece) return;
945
+
946
+ const positions = this.currentPiece.getWorldPositions();
947
+
948
+ // Check for game over (piece locked above playfield)
949
+ const abovePlayfield = positions.some((pos) => pos.y < 0);
950
+ if (abovePlayfield) {
951
+ this._gameOver();
952
+ return;
953
+ }
954
+
955
+ // Place piece in grid
956
+ this.grid.placePiece(positions, this.currentPiece.color);
957
+
958
+ // Store positions for bounce animation
959
+ const lockedPositions = [...positions];
960
+
961
+ // Check for line clears
962
+ const { clearedCount, clearedLayers } = this.grid.checkAndClearLayers();
963
+
964
+ if (clearedCount > 0) {
965
+ // Add score
966
+ this._addLineScore(clearedCount);
967
+
968
+ // Update lines and level
969
+ this.linesCleared += clearedCount;
970
+ const newLevel = Math.floor(this.linesCleared / CONFIG.leveling.linesPerLevel) + 1;
971
+ if (newLevel > this.level && newLevel <= CONFIG.leveling.maxLevel) {
972
+ this.level = newLevel;
973
+ }
974
+ }
975
+
976
+ // Clear current piece
977
+ this.currentPiece = null;
978
+ this.isLocking = false;
979
+
980
+ // Update UI
981
+ this._updateUI();
982
+
983
+ // Update renderers with bounce animation on locked positions
984
+ this._updateRenderersWithBounce(lockedPositions);
985
+
986
+ // Spawn next piece
987
+ this._spawnPiece();
988
+ }
989
+
990
+ /**
991
+ * Add score for cleared lines
992
+ * @param {number} count
993
+ * @private
994
+ */
995
+ _addLineScore(count) {
996
+ const scoring = CONFIG.scoring;
997
+ let points = 0;
998
+
999
+ switch (count) {
1000
+ case 1:
1001
+ points = scoring.single;
1002
+ break;
1003
+ case 2:
1004
+ points = scoring.double;
1005
+ break;
1006
+ case 3:
1007
+ points = scoring.triple;
1008
+ break;
1009
+ case 4:
1010
+ default:
1011
+ points = scoring.tetris;
1012
+ break;
1013
+ }
1014
+
1015
+ // Level multiplier
1016
+ points *= this.level;
1017
+
1018
+ this.score += points;
1019
+ }
1020
+
1021
+ /**
1022
+ * Update UI text elements
1023
+ * @private
1024
+ */
1025
+ _updateUI() {
1026
+ this.scoreText.text = `SCORE: ${this.score}`;
1027
+ this.levelText.text = `LEVEL: ${this.level}`;
1028
+ this.linesText.text = `LINES: ${this.linesCleared}`;
1029
+ }
1030
+
1031
+ /**
1032
+ * Update block renderers
1033
+ * @private
1034
+ */
1035
+ _updateRenderers() {
1036
+ // Update piece cubes
1037
+ this.blockRenderer.updatePiece(this.currentPiece);
1038
+
1039
+ // Update ghost piece
1040
+ if (this.currentPiece) {
1041
+ const landingY = this.grid.calculateLandingY(this.currentPiece);
1042
+ this.blockRenderer.updateGhost(this.currentPiece, landingY);
1043
+ } else {
1044
+ this.blockRenderer.updateGhost(null, 0);
1045
+ }
1046
+
1047
+ // Update hint ghost
1048
+ this.blockRenderer.updateHint(this.hintPositions);
1049
+
1050
+ // Update grid cubes
1051
+ this.blockRenderer.updateGrid(this.grid.getFilledCells());
1052
+ }
1053
+
1054
+ /**
1055
+ * Update renderers with bounce animation for newly locked positions
1056
+ * @param {Array<{x: number, y: number, z: number}>} lockedPositions
1057
+ * @private
1058
+ */
1059
+ _updateRenderersWithBounce(lockedPositions) {
1060
+ // Update piece cubes
1061
+ this.blockRenderer.updatePiece(this.currentPiece);
1062
+
1063
+ // Update ghost piece
1064
+ this.blockRenderer.updateGhost(null, 0);
1065
+
1066
+ // Update grid cubes with bounce on new positions
1067
+ this.blockRenderer.updateGrid(this.grid.getFilledCells(), lockedPositions);
1068
+ }
1069
+
1070
+ /**
1071
+ * Calculate current fall speed
1072
+ * @returns {number} Seconds per row
1073
+ * @private
1074
+ */
1075
+ _getFallSpeed() {
1076
+ let speed = CONFIG.timing.fallSpeed;
1077
+
1078
+ // Apply level speed increase
1079
+ for (let i = 1; i < this.level; i++) {
1080
+ speed *= CONFIG.leveling.speedMultiplier;
1081
+ }
1082
+
1083
+ // Apply soft drop multiplier
1084
+ if (this.softDropping) {
1085
+ speed /= CONFIG.timing.softDropMultiplier;
1086
+ }
1087
+
1088
+ return speed;
1089
+ }
1090
+
1091
+ update(dt) {
1092
+ super.update(dt);
1093
+
1094
+ // Update camera
1095
+ this.camera.update(dt);
1096
+
1097
+ // Don't update game logic if not playing
1098
+ if (this.gameState !== GameState.PLAYING) return;
1099
+
1100
+ if (!this.currentPiece) return;
1101
+
1102
+ // Handle auto-play mode
1103
+ if (this.autoPlayEnabled) {
1104
+ this.autoPlayTimer += dt;
1105
+ if (this.autoPlayTimer >= this.autoPlayDelay) {
1106
+ this.autoPlayTimer = 0;
1107
+ this._autoPlayMove();
1108
+ return; // Skip normal falling logic when auto-playing
1109
+ }
1110
+ }
1111
+
1112
+ // Handle falling
1113
+ this.fallTimer += dt;
1114
+ const fallSpeed = this._getFallSpeed();
1115
+
1116
+ if (this.fallTimer >= fallSpeed) {
1117
+ this.fallTimer = 0;
1118
+
1119
+ // Try to move down
1120
+ const moved = this._tryMove(0, 1, 0);
1121
+
1122
+ if (!moved) {
1123
+ // Piece hit something, start lock timer
1124
+ if (!this.isLocking) {
1125
+ this.isLocking = true;
1126
+ this.lockTimer = 0;
1127
+ }
1128
+ } else {
1129
+ // Piece moved, add soft drop score
1130
+ if (this.softDropping) {
1131
+ this.score += CONFIG.scoring.softDrop;
1132
+ }
1133
+
1134
+ // Reset locking if we're moving
1135
+ if (this.isLocking) {
1136
+ // Check if there's still ground below
1137
+ const checkPositions = this.currentPiece.voxels.map((v) => ({
1138
+ x: this.currentPiece.x + v.x,
1139
+ y: this.currentPiece.y + v.y + 1,
1140
+ z: this.currentPiece.z + v.z,
1141
+ }));
1142
+
1143
+ if (this.grid.canPlace(checkPositions)) {
1144
+ this.isLocking = false;
1145
+ this.lockTimer = 0;
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ // Handle lock delay
1152
+ if (this.isLocking) {
1153
+ this.lockTimer += dt;
1154
+
1155
+ if (this.lockTimer >= CONFIG.timing.lockDelay) {
1156
+ this._lockPiece();
1157
+ }
1158
+ }
1159
+ }
1160
+
1161
+ render() {
1162
+ const ctx = this.ctx;
1163
+ const centerX = this.width / 2;
1164
+ const centerY = this.height / 2;
1165
+
1166
+ // Clear canvas manually
1167
+ ctx.fillStyle = this.backgroundColor;
1168
+ ctx.fillRect(0, 0, this.width, this.height);
1169
+
1170
+ // Render 3D content first
1171
+ this.wellRenderer.render(ctx, centerX, centerY);
1172
+ this.blockRenderer.render(ctx, centerX, centerY);
1173
+
1174
+ // Render next piece preview
1175
+ const previewX = this.width - 70;
1176
+ const previewY = 80;
1177
+ this.nextPieceRenderer.render(ctx, previewX, previewY);
1178
+
1179
+ // Draw overlay for game over / paused / ready states
1180
+ if (this.gameState !== GameState.PLAYING) {
1181
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
1182
+ ctx.fillRect(0, 0, this.width, this.height);
1183
+ }
1184
+
1185
+ // Render UI pipeline last (text on top of overlay)
1186
+ this.pipeline.render();
1187
+ }
1188
+ }
1189
+
1190
+ // Start the game
1191
+ window.addEventListener("load", () => {
1192
+ const canvas = document.getElementById("game");
1193
+ const game = new Tetris3DGame(canvas);
1194
+ game.start();
1195
+ });