@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.
- package/dist/CNAME +1 -0
- package/dist/animations.html +31 -0
- package/dist/basic.html +38 -0
- package/dist/baskara.html +31 -0
- package/dist/bezier.html +35 -0
- package/dist/beziersignature.html +29 -0
- package/dist/blackhole.html +28 -0
- package/dist/blob.html +35 -0
- package/dist/coordinates.html +698 -0
- package/dist/cube3d.html +23 -0
- package/dist/demos.css +303 -0
- package/dist/dino.html +42 -0
- package/dist/easing.html +28 -0
- package/dist/events.html +195 -0
- package/dist/fluent.html +647 -0
- package/dist/fluid-simple.html +22 -0
- package/dist/fluid.html +37 -0
- package/dist/fractals.html +36 -0
- package/dist/gameobjects.html +626 -0
- package/dist/gcanvas.es.js +517 -0
- package/dist/gcanvas.es.min.js +1 -1
- package/dist/gcanvas.umd.js +1 -1
- package/dist/gcanvas.umd.min.js +1 -1
- package/dist/genart.html +26 -0
- package/dist/gendream.html +26 -0
- package/dist/group.html +36 -0
- package/dist/home.html +587 -0
- package/dist/hyperbolic001.html +23 -0
- package/dist/hyperbolic002.html +23 -0
- package/dist/hyperbolic003.html +23 -0
- package/dist/hyperbolic004.html +23 -0
- package/dist/hyperbolic005.html +22 -0
- package/dist/index.html +398 -0
- package/dist/isometric.html +34 -0
- package/dist/js/animations.js +452 -0
- package/dist/js/basic.js +204 -0
- package/dist/js/baskara.js +751 -0
- package/dist/js/bezier.js +692 -0
- package/dist/js/beziersignature.js +241 -0
- package/dist/js/blackhole/accretiondisk.obj.js +379 -0
- package/dist/js/blackhole/blackhole.obj.js +318 -0
- package/dist/js/blackhole/index.js +409 -0
- package/dist/js/blackhole/particle.js +56 -0
- package/dist/js/blackhole/starfield.obj.js +218 -0
- package/dist/js/blob.js +2276 -0
- package/dist/js/coordinates.js +840 -0
- package/dist/js/cube3d.js +789 -0
- package/dist/js/dino.js +1420 -0
- package/dist/js/easing.js +477 -0
- package/dist/js/fluent.js +183 -0
- package/dist/js/fluid-simple.js +253 -0
- package/dist/js/fluid.js +527 -0
- package/dist/js/fractals.js +932 -0
- package/dist/js/fractalworker.js +93 -0
- package/dist/js/gameobjects.js +176 -0
- package/dist/js/genart.js +268 -0
- package/dist/js/gendream.js +209 -0
- package/dist/js/group.js +140 -0
- package/dist/js/hyperbolic001.js +310 -0
- package/dist/js/hyperbolic002.js +388 -0
- package/dist/js/hyperbolic003.js +319 -0
- package/dist/js/hyperbolic004.js +345 -0
- package/dist/js/hyperbolic005.js +340 -0
- package/dist/js/info-toggle.js +25 -0
- package/dist/js/isometric.js +863 -0
- package/dist/js/kerr.js +1547 -0
- package/dist/js/lavalamp.js +590 -0
- package/dist/js/layout.js +354 -0
- package/dist/js/mondrian.js +285 -0
- package/dist/js/opacity.js +275 -0
- package/dist/js/painter.js +484 -0
- package/dist/js/particles-showcase.js +514 -0
- package/dist/js/particles.js +299 -0
- package/dist/js/patterns.js +397 -0
- package/dist/js/penrose/artifact.js +69 -0
- package/dist/js/penrose/blackhole.js +121 -0
- package/dist/js/penrose/constants.js +73 -0
- package/dist/js/penrose/game.js +943 -0
- package/dist/js/penrose/lore.js +278 -0
- package/dist/js/penrose/penrosescene.js +892 -0
- package/dist/js/penrose/ship.js +216 -0
- package/dist/js/penrose/sounds.js +211 -0
- package/dist/js/penrose/voidparticle.js +55 -0
- package/dist/js/penrose/voidscene.js +258 -0
- package/dist/js/penrose/voidship.js +144 -0
- package/dist/js/penrose/wormhole.js +46 -0
- package/dist/js/pipeline.js +555 -0
- package/dist/js/plane3d.js +256 -0
- package/dist/js/platformer.js +1579 -0
- package/dist/js/scene.js +304 -0
- package/dist/js/scenes.js +320 -0
- package/dist/js/schrodinger.js +410 -0
- package/dist/js/schwarzschild.js +1015 -0
- package/dist/js/shapes.js +628 -0
- package/dist/js/space/alien.js +171 -0
- package/dist/js/space/boom.js +98 -0
- package/dist/js/space/boss.js +353 -0
- package/dist/js/space/buff.js +73 -0
- package/dist/js/space/bullet.js +102 -0
- package/dist/js/space/constants.js +85 -0
- package/dist/js/space/game.js +1884 -0
- package/dist/js/space/hud.js +112 -0
- package/dist/js/space/laserbeam.js +179 -0
- package/dist/js/space/lightning.js +277 -0
- package/dist/js/space/minion.js +192 -0
- package/dist/js/space/missile.js +212 -0
- package/dist/js/space/player.js +430 -0
- package/dist/js/space/powerup.js +90 -0
- package/dist/js/space/starfield.js +58 -0
- package/dist/js/space/starpower.js +90 -0
- package/dist/js/spacetime.js +559 -0
- package/dist/js/sphere3d.js +229 -0
- package/dist/js/sprite.js +473 -0
- package/dist/js/starfaux/config.js +118 -0
- package/dist/js/starfaux/enemy.js +353 -0
- package/dist/js/starfaux/hud.js +78 -0
- package/dist/js/starfaux/index.js +482 -0
- package/dist/js/starfaux/laser.js +182 -0
- package/dist/js/starfaux/player.js +468 -0
- package/dist/js/starfaux/terrain.js +560 -0
- package/dist/js/study001.js +275 -0
- package/dist/js/study002.js +366 -0
- package/dist/js/study003.js +331 -0
- package/dist/js/study004.js +389 -0
- package/dist/js/study005.js +209 -0
- package/dist/js/study006.js +194 -0
- package/dist/js/study007.js +192 -0
- package/dist/js/study008.js +413 -0
- package/dist/js/svgtween.js +204 -0
- package/dist/js/tde/accretiondisk.js +471 -0
- package/dist/js/tde/blackhole.js +219 -0
- package/dist/js/tde/blackholescene.js +209 -0
- package/dist/js/tde/config.js +59 -0
- package/dist/js/tde/index.js +820 -0
- package/dist/js/tde/jets.js +290 -0
- package/dist/js/tde/lensedstarfield.js +154 -0
- package/dist/js/tde/tdestar.js +297 -0
- package/dist/js/tde/tidalstream.js +372 -0
- package/dist/js/tde_old/blackhole.obj.js +354 -0
- package/dist/js/tde_old/debris.obj.js +791 -0
- package/dist/js/tde_old/flare.obj.js +239 -0
- package/dist/js/tde_old/index.js +448 -0
- package/dist/js/tde_old/star.obj.js +812 -0
- package/dist/js/tetris/config.js +157 -0
- package/dist/js/tetris/grid.js +286 -0
- package/dist/js/tetris/index.js +1195 -0
- package/dist/js/tetris/renderer.js +634 -0
- package/dist/js/tetris/tetrominos.js +280 -0
- package/dist/js/tiles.js +312 -0
- package/dist/js/tweendemo.js +79 -0
- package/dist/js/visibility.js +102 -0
- package/dist/kerr.html +28 -0
- package/dist/lavalamp.html +27 -0
- package/dist/layouts.html +37 -0
- package/dist/logo.svg +4 -0
- package/dist/loop.html +84 -0
- package/dist/mondrian.html +32 -0
- package/dist/og_image.png +0 -0
- package/dist/opacity.html +36 -0
- package/dist/painter.html +39 -0
- package/dist/particles-showcase.html +28 -0
- package/dist/particles.html +24 -0
- package/dist/patterns.html +33 -0
- package/dist/penrose-game.html +31 -0
- package/dist/pipeline.html +737 -0
- package/dist/plane3d.html +24 -0
- package/dist/platformer.html +43 -0
- package/dist/scene.html +33 -0
- package/dist/scenes.html +96 -0
- package/dist/schrodinger.html +27 -0
- package/dist/schwarzschild.html +27 -0
- package/dist/shapes.html +16 -0
- package/dist/space.html +85 -0
- package/dist/spacetime.html +27 -0
- package/dist/sphere3d.html +24 -0
- package/dist/sprite.html +18 -0
- package/dist/starfaux.html +22 -0
- package/dist/study001.html +23 -0
- package/dist/study002.html +23 -0
- package/dist/study003.html +23 -0
- package/dist/study004.html +23 -0
- package/dist/study005.html +22 -0
- package/dist/study006.html +24 -0
- package/dist/study007.html +24 -0
- package/dist/study008.html +22 -0
- package/dist/svgtween.html +29 -0
- package/dist/tde.html +28 -0
- package/dist/tetris3d.html +25 -0
- package/dist/tiles.html +28 -0
- package/dist/transforms.html +400 -0
- package/dist/tween.html +45 -0
- package/dist/visibility.html +33 -0
- 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
|
+
});
|