luva 0.0.3
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/.prettierrc +3 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/examples/minimal/app.lumo +0 -0
- package/examples/minimal/index.js +11 -0
- package/examples/minimal/lumobase.jsonc +5 -0
- package/examples/minimal/package-lock.json +1538 -0
- package/examples/minimal/package.json +12 -0
- package/examples/minimal/public/icon.png +0 -0
- package/examples/minimal/wrangler.jsonc +6 -0
- package/examples/snake/icon.png +0 -0
- package/examples/snake/lumobase.jsonc +11 -0
- package/examples/snake/package-lock.json +1008 -0
- package/examples/snake/package.json +13 -0
- package/examples/snake/public/index.html +602 -0
- package/examples/snake/snake.lumo +0 -0
- package/package.json +28 -0
- package/src/cli.ts +22 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snake",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"clean": "rm -rf dist && rm -f snake.luva",
|
|
6
|
+
"dist": "npm run clean && mkdir dist && cp -r luva.jsonc icon.png public dist/",
|
|
7
|
+
"build:luva": "npm run dist && cd dist && zip -r ../snake.luva .",
|
|
8
|
+
"dev": "vite dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"vite": "^6.3.5"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
7
|
+
<title>Snake Game</title>
|
|
8
|
+
<style>
|
|
9
|
+
* {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
background: linear-gradient(135deg, #f7f3ea 0%, #f0ead6 100%);
|
|
17
|
+
display: flex;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
align-items: center;
|
|
20
|
+
min-height: 100vh;
|
|
21
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
touch-action: none;
|
|
24
|
+
-webkit-touch-callout: none;
|
|
25
|
+
-webkit-user-select: none;
|
|
26
|
+
user-select: none;
|
|
27
|
+
-webkit-tap-highlight-color: transparent;
|
|
28
|
+
padding: 20px;
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
canvas {
|
|
33
|
+
border: none;
|
|
34
|
+
background: #ffffff;
|
|
35
|
+
border-radius: 20px;
|
|
36
|
+
box-shadow:
|
|
37
|
+
0 10px 30px rgba(0, 0, 0, 0.1),
|
|
38
|
+
0 1px 8px rgba(0, 0, 0, 0.05),
|
|
39
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
|
40
|
+
max-width: 100%;
|
|
41
|
+
max-height: 100%;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.game-info {
|
|
45
|
+
position: absolute;
|
|
46
|
+
top: 30px;
|
|
47
|
+
left: 30px;
|
|
48
|
+
color: #6b5b47;
|
|
49
|
+
font-size: 20px;
|
|
50
|
+
z-index: 10;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
background: rgba(255, 255, 255, 0.9);
|
|
53
|
+
padding: 12px 16px;
|
|
54
|
+
border-radius: 12px;
|
|
55
|
+
backdrop-filter: blur(10px);
|
|
56
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.game-over {
|
|
60
|
+
position: absolute;
|
|
61
|
+
top: 50%;
|
|
62
|
+
left: 50%;
|
|
63
|
+
transform: translate(-50%, -50%);
|
|
64
|
+
color: #6b5b47;
|
|
65
|
+
text-align: center;
|
|
66
|
+
z-index: 20;
|
|
67
|
+
display: none;
|
|
68
|
+
background: rgba(255, 255, 255, 0.95);
|
|
69
|
+
padding: 48px 40px;
|
|
70
|
+
border-radius: 24px;
|
|
71
|
+
backdrop-filter: blur(20px);
|
|
72
|
+
box-shadow:
|
|
73
|
+
0 20px 40px rgba(0, 0, 0, 0.15),
|
|
74
|
+
0 8px 16px rgba(0, 0, 0, 0.1);
|
|
75
|
+
border: 1px solid rgba(255, 255, 255, 0.8);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.game-over h2 {
|
|
79
|
+
font-size: 32px;
|
|
80
|
+
margin-bottom: 20px;
|
|
81
|
+
color: #e74c3c;
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: -0.5px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.game-over p {
|
|
87
|
+
font-size: 18px;
|
|
88
|
+
margin-bottom: 12px;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
line-height: 1.4;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.controls {
|
|
94
|
+
position: absolute;
|
|
95
|
+
bottom: 30px;
|
|
96
|
+
left: 30px;
|
|
97
|
+
color: #8b7d63;
|
|
98
|
+
font-size: 14px;
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
background: rgba(255, 255, 255, 0.8);
|
|
101
|
+
padding: 10px 14px;
|
|
102
|
+
border-radius: 10px;
|
|
103
|
+
backdrop-filter: blur(10px);
|
|
104
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.desktop-only {
|
|
108
|
+
display: block;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.mobile-only {
|
|
112
|
+
display: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@media (max-width: 768px) {
|
|
116
|
+
.game-info {
|
|
117
|
+
top: 20px;
|
|
118
|
+
left: 20px;
|
|
119
|
+
right: 20px;
|
|
120
|
+
font-size: 18px;
|
|
121
|
+
padding: 10px 14px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.controls {
|
|
125
|
+
bottom: 20px;
|
|
126
|
+
left: 20px;
|
|
127
|
+
right: 20px;
|
|
128
|
+
text-align: center;
|
|
129
|
+
font-size: 13px;
|
|
130
|
+
padding: 8px 12px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.game-over {
|
|
134
|
+
left: 20px;
|
|
135
|
+
right: 20px;
|
|
136
|
+
transform: translateY(-50%);
|
|
137
|
+
padding: 32px 24px;
|
|
138
|
+
margin: 0 auto;
|
|
139
|
+
max-width: 320px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.desktop-only {
|
|
143
|
+
display: none;
|
|
144
|
+
}
|
|
145
|
+
.mobile-only {
|
|
146
|
+
display: block;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
</style>
|
|
150
|
+
</head>
|
|
151
|
+
|
|
152
|
+
<body>
|
|
153
|
+
<div class="game-info">
|
|
154
|
+
<div>Score: <span id="score">0</span></div>
|
|
155
|
+
<div>High Score: <span id="highScore">0</span></div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div class="game-over" id="gameOver">
|
|
159
|
+
<h2>Game Over!</h2>
|
|
160
|
+
<p>Final Score: <span id="finalScore">0</span></p>
|
|
161
|
+
<p>Tap to restart</p>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="controls">
|
|
165
|
+
<p class="desktop-only">Use ARROW KEYS or WASD to move</p>
|
|
166
|
+
<p class="mobile-only">Swipe to move</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<canvas id="gameCanvas" tabindex="0"></canvas>
|
|
170
|
+
|
|
171
|
+
<script>
|
|
172
|
+
class SnakeGame {
|
|
173
|
+
constructor() {
|
|
174
|
+
this.canvas = document.getElementById('gameCanvas');
|
|
175
|
+
this.ctx = this.canvas.getContext('2d');
|
|
176
|
+
this.scoreElement = document.getElementById('score');
|
|
177
|
+
this.highScoreElement = document.getElementById('highScore');
|
|
178
|
+
this.gameOverElement = document.getElementById('gameOver');
|
|
179
|
+
this.finalScoreElement = document.getElementById('finalScore');
|
|
180
|
+
|
|
181
|
+
this.gridSize = 32;
|
|
182
|
+
this.tileCount = 0;
|
|
183
|
+
|
|
184
|
+
this.snake = [];
|
|
185
|
+
this.food = {};
|
|
186
|
+
this.dx = 0;
|
|
187
|
+
this.dy = 0;
|
|
188
|
+
this.score = 0;
|
|
189
|
+
this.highScore = localStorage.getItem('snakeHighScore') || 0;
|
|
190
|
+
this.gameRunning = false;
|
|
191
|
+
this.gameSpeed = 150;
|
|
192
|
+
this.lastFrameTime = 0;
|
|
193
|
+
this.inputBuffer = [];
|
|
194
|
+
this.sounds = {
|
|
195
|
+
eat: this.createSound(800, 0.1, 'square'),
|
|
196
|
+
gameOver: this.createSound(200, 0.5, 'sawtooth')
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
this.setupCanvas();
|
|
200
|
+
this.canvas.focus();
|
|
201
|
+
this.initGame();
|
|
202
|
+
this.bindEvents();
|
|
203
|
+
this.gameLoop();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setupCanvas() {
|
|
207
|
+
// Better mobile spacing and responsive design
|
|
208
|
+
const isMobile = window.innerWidth <= 768;
|
|
209
|
+
const padding = isMobile ? 40 : 80;
|
|
210
|
+
const maxWidth = window.innerWidth - padding;
|
|
211
|
+
const maxHeight = window.innerHeight - padding - (isMobile ? 120 : 160); // Account for UI elements
|
|
212
|
+
|
|
213
|
+
// Make grid size proportional to canvas size for better gameplay
|
|
214
|
+
const minDimension = Math.min(maxWidth, maxHeight);
|
|
215
|
+
this.tileCount = Math.max(12, Math.floor(minDimension / this.gridSize));
|
|
216
|
+
|
|
217
|
+
this.canvas.width = this.tileCount * this.gridSize;
|
|
218
|
+
this.canvas.height = this.tileCount * this.gridSize;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
initGame() {
|
|
222
|
+
const centerY = Math.floor(this.tileCount / 2);
|
|
223
|
+
this.snake = [
|
|
224
|
+
{ x: 2, y: centerY },
|
|
225
|
+
{ x: 1, y: centerY },
|
|
226
|
+
{ x: 0, y: centerY }
|
|
227
|
+
];
|
|
228
|
+
this.dx = 1;
|
|
229
|
+
this.dy = 0;
|
|
230
|
+
this.score = 0;
|
|
231
|
+
this.gameRunning = true;
|
|
232
|
+
this.gameSpeed = 150;
|
|
233
|
+
this.lastFrameTime = 0;
|
|
234
|
+
this.inputBuffer = [];
|
|
235
|
+
this.highScoreElement.textContent = this.highScore;
|
|
236
|
+
this.scoreElement.textContent = this.score;
|
|
237
|
+
this.gameOverElement.style.display = 'none';
|
|
238
|
+
this.generateFood();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
generateFood() {
|
|
242
|
+
const availablePositions = [];
|
|
243
|
+
|
|
244
|
+
// Find all positions not occupied by snake
|
|
245
|
+
for (let x = 0; x < this.tileCount; x++) {
|
|
246
|
+
for (let y = 0; y < this.tileCount; y++) {
|
|
247
|
+
const isOccupied = this.snake.some(segment => segment.x === x && segment.y === y);
|
|
248
|
+
if (!isOccupied) {
|
|
249
|
+
availablePositions.push({ x, y });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// If no available positions (game won), place food anywhere
|
|
255
|
+
if (availablePositions.length === 0) {
|
|
256
|
+
this.food = {
|
|
257
|
+
x: Math.floor(Math.random() * this.tileCount),
|
|
258
|
+
y: Math.floor(Math.random() * this.tileCount)
|
|
259
|
+
};
|
|
260
|
+
} else {
|
|
261
|
+
const randomIndex = Math.floor(Math.random() * availablePositions.length);
|
|
262
|
+
this.food = availablePositions[randomIndex];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
bindEvents() {
|
|
267
|
+
document.addEventListener('keydown', (e) => {
|
|
268
|
+
if (!this.gameRunning) return;
|
|
269
|
+
|
|
270
|
+
const keyMap = {
|
|
271
|
+
'ArrowUp': { dx: 0, dy: -1 },
|
|
272
|
+
'ArrowDown': { dx: 0, dy: 1 },
|
|
273
|
+
'ArrowLeft': { dx: -1, dy: 0 },
|
|
274
|
+
'ArrowRight': { dx: 1, dy: 0 },
|
|
275
|
+
'KeyW': { dx: 0, dy: -1 },
|
|
276
|
+
'KeyS': { dx: 0, dy: 1 },
|
|
277
|
+
'KeyA': { dx: -1, dy: 0 },
|
|
278
|
+
'KeyD': { dx: 1, dy: 0 }
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const direction = keyMap[e.code];
|
|
282
|
+
if (direction) {
|
|
283
|
+
this.setDirection(direction.dx, direction.dy);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Touch tap to restart when game over
|
|
288
|
+
this.gameOverElement.addEventListener('touchend', (e) => {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
if (!this.gameRunning) {
|
|
291
|
+
this.initGame();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
this.gameOverElement.addEventListener('click', (e) => {
|
|
296
|
+
if (!this.gameRunning) {
|
|
297
|
+
this.initGame();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
window.addEventListener('resize', () => {
|
|
302
|
+
this.setupCanvas();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
this.canvas.addEventListener('click', () => {
|
|
306
|
+
this.canvas.focus();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Touch swipe controls
|
|
310
|
+
let touchStartX = 0;
|
|
311
|
+
let touchStartY = 0;
|
|
312
|
+
const minSwipeDistance = 30;
|
|
313
|
+
|
|
314
|
+
this.canvas.addEventListener('touchstart', (e) => {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
const touch = e.touches[0];
|
|
317
|
+
touchStartX = touch.clientX;
|
|
318
|
+
touchStartY = touch.clientY;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
this.canvas.addEventListener('touchend', (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
if (!e.changedTouches) return;
|
|
324
|
+
|
|
325
|
+
const touch = e.changedTouches[0];
|
|
326
|
+
const touchEndX = touch.clientX;
|
|
327
|
+
const touchEndY = touch.clientY;
|
|
328
|
+
|
|
329
|
+
const deltaX = touchEndX - touchStartX;
|
|
330
|
+
const deltaY = touchEndY - touchStartY;
|
|
331
|
+
|
|
332
|
+
// Check if swipe distance is sufficient
|
|
333
|
+
if (Math.abs(deltaX) < minSwipeDistance && Math.abs(deltaY) < minSwipeDistance) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Determine swipe direction
|
|
338
|
+
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
|
339
|
+
// Horizontal swipe
|
|
340
|
+
if (deltaX > 0) {
|
|
341
|
+
this.handleDirectionInput('right');
|
|
342
|
+
} else {
|
|
343
|
+
this.handleDirectionInput('left');
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
// Vertical swipe
|
|
347
|
+
if (deltaY > 0) {
|
|
348
|
+
this.handleDirectionInput('down');
|
|
349
|
+
} else {
|
|
350
|
+
this.handleDirectionInput('up');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
handleDirectionInput(direction) {
|
|
357
|
+
if (!this.gameRunning) return;
|
|
358
|
+
|
|
359
|
+
const directionMap = {
|
|
360
|
+
'up': { dx: 0, dy: -1 },
|
|
361
|
+
'down': { dx: 0, dy: 1 },
|
|
362
|
+
'left': { dx: -1, dy: 0 },
|
|
363
|
+
'right': { dx: 1, dy: 0 }
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const dir = directionMap[direction];
|
|
367
|
+
if (dir) {
|
|
368
|
+
this.setDirection(dir.dx, dir.dy);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setDirection(dx, dy) {
|
|
373
|
+
// Add to input buffer instead of setting directly
|
|
374
|
+
if (this.inputBuffer.length < 3) {
|
|
375
|
+
this.inputBuffer.push({ dx, dy });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
processInputBuffer() {
|
|
380
|
+
if (this.inputBuffer.length === 0) return;
|
|
381
|
+
|
|
382
|
+
const nextInput = this.inputBuffer[0];
|
|
383
|
+
|
|
384
|
+
// Prevent reversing into self
|
|
385
|
+
if (this.snake.length > 1) {
|
|
386
|
+
if (nextInput.dx === -this.dx && nextInput.dy === -this.dy) {
|
|
387
|
+
this.inputBuffer.shift(); // Remove invalid input
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Apply the direction change
|
|
393
|
+
this.dx = nextInput.dx;
|
|
394
|
+
this.dy = nextInput.dy;
|
|
395
|
+
this.inputBuffer.shift(); // Remove processed input
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
update() {
|
|
399
|
+
if (!this.gameRunning) return;
|
|
400
|
+
|
|
401
|
+
// Process buffered input
|
|
402
|
+
this.processInputBuffer();
|
|
403
|
+
|
|
404
|
+
// Don't update if snake isn't moving yet
|
|
405
|
+
if (this.dx === 0 && this.dy === 0) return;
|
|
406
|
+
|
|
407
|
+
let head = { x: this.snake[0].x + this.dx, y: this.snake[0].y + this.dy };
|
|
408
|
+
|
|
409
|
+
// Wrap around walls instead of collision
|
|
410
|
+
if (head.x < 0) {
|
|
411
|
+
head.x = this.tileCount - 1;
|
|
412
|
+
} else if (head.x >= this.tileCount) {
|
|
413
|
+
head.x = 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (head.y < 0) {
|
|
417
|
+
head.y = this.tileCount - 1;
|
|
418
|
+
} else if (head.y >= this.tileCount) {
|
|
419
|
+
head.y = 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Check self collision
|
|
423
|
+
for (let segment of this.snake) {
|
|
424
|
+
if (head.x === segment.x && head.y === segment.y) {
|
|
425
|
+
this.gameOver();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this.snake.unshift(head);
|
|
431
|
+
|
|
432
|
+
// Check food collision
|
|
433
|
+
if (head.x === this.food.x && head.y === this.food.y) {
|
|
434
|
+
this.score += 10;
|
|
435
|
+
this.scoreElement.textContent = this.score;
|
|
436
|
+
|
|
437
|
+
// Play eat sound
|
|
438
|
+
this.playSound(this.sounds.eat);
|
|
439
|
+
|
|
440
|
+
// Increase speed slightly with each food eaten
|
|
441
|
+
this.gameSpeed = Math.max(80, this.gameSpeed - 3);
|
|
442
|
+
|
|
443
|
+
this.generateFood();
|
|
444
|
+
} else {
|
|
445
|
+
this.snake.pop();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
draw() {
|
|
450
|
+
// Clear canvas with subtle gradient background
|
|
451
|
+
const gradient = this.ctx.createRadialGradient(
|
|
452
|
+
this.canvas.width / 2, this.canvas.height / 2, 0,
|
|
453
|
+
this.canvas.width / 2, this.canvas.height / 2, this.canvas.width / 2
|
|
454
|
+
);
|
|
455
|
+
gradient.addColorStop(0, '#f7f3ea');
|
|
456
|
+
gradient.addColorStop(1, '#f0ead6');
|
|
457
|
+
this.ctx.fillStyle = gradient;
|
|
458
|
+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
459
|
+
|
|
460
|
+
// Draw snake with clean, playful design
|
|
461
|
+
for (let i = 0; i < this.snake.length; i++) {
|
|
462
|
+
const segment = this.snake[i];
|
|
463
|
+
const x = segment.x * this.gridSize + 4;
|
|
464
|
+
const y = segment.y * this.gridSize + 4;
|
|
465
|
+
const size = this.gridSize - 8;
|
|
466
|
+
const radius = size / 2;
|
|
467
|
+
|
|
468
|
+
if (i === 0) {
|
|
469
|
+
// Snake head - simple rounded rectangle with eyes
|
|
470
|
+
this.ctx.fillStyle = '#7ba05b';
|
|
471
|
+
this.ctx.beginPath();
|
|
472
|
+
this.ctx.roundRect(x, y, size, size, radius * 0.6);
|
|
473
|
+
this.ctx.fill();
|
|
474
|
+
|
|
475
|
+
// Simple eyes
|
|
476
|
+
this.ctx.fillStyle = '#2d3436';
|
|
477
|
+
const eyeSize = Math.max(3, this.gridSize / 12);
|
|
478
|
+
const eyeOffset = radius * 0.4;
|
|
479
|
+
|
|
480
|
+
this.ctx.beginPath();
|
|
481
|
+
this.ctx.arc(x + radius - eyeOffset, y + radius - eyeOffset * 0.3, eyeSize, 0, 2 * Math.PI);
|
|
482
|
+
this.ctx.arc(x + radius + eyeOffset, y + radius - eyeOffset * 0.3, eyeSize, 0, 2 * Math.PI);
|
|
483
|
+
this.ctx.fill();
|
|
484
|
+
|
|
485
|
+
// Small white eye highlights
|
|
486
|
+
this.ctx.fillStyle = '#ffffff';
|
|
487
|
+
const highlightSize = eyeSize * 0.3;
|
|
488
|
+
this.ctx.beginPath();
|
|
489
|
+
this.ctx.arc(x + radius - eyeOffset + 1, y + radius - eyeOffset * 0.3 - 1, highlightSize, 0, 2 * Math.PI);
|
|
490
|
+
this.ctx.arc(x + radius + eyeOffset + 1, y + radius - eyeOffset * 0.3 - 1, highlightSize, 0, 2 * Math.PI);
|
|
491
|
+
this.ctx.fill();
|
|
492
|
+
} else {
|
|
493
|
+
// Body segments - simple rounded rectangles
|
|
494
|
+
this.ctx.fillStyle = '#8fbc8f';
|
|
495
|
+
this.ctx.beginPath();
|
|
496
|
+
this.ctx.roundRect(x, y, size, size, radius * 0.4);
|
|
497
|
+
this.ctx.fill();
|
|
498
|
+
|
|
499
|
+
// Simple scale pattern - just a small dot
|
|
500
|
+
this.ctx.fillStyle = '#689f38';
|
|
501
|
+
this.ctx.beginPath();
|
|
502
|
+
this.ctx.arc(x + radius, y + radius, Math.max(1, this.gridSize / 20), 0, 2 * Math.PI);
|
|
503
|
+
this.ctx.fill();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Draw enhanced apple food
|
|
508
|
+
const foodX = this.food.x * this.gridSize + 8;
|
|
509
|
+
const foodY = this.food.y * this.gridSize + 8;
|
|
510
|
+
const appleSize = this.gridSize - 16;
|
|
511
|
+
const appleRadius = appleSize / 2;
|
|
512
|
+
|
|
513
|
+
// Apple body with gradient
|
|
514
|
+
const appleGradient = this.ctx.createRadialGradient(
|
|
515
|
+
foodX + appleRadius * 0.6, foodY + appleRadius * 0.6, 0,
|
|
516
|
+
foodX + appleRadius, foodY + appleRadius, appleRadius
|
|
517
|
+
);
|
|
518
|
+
appleGradient.addColorStop(0, '#ff6b6b');
|
|
519
|
+
appleGradient.addColorStop(1, '#e74c3c');
|
|
520
|
+
this.ctx.fillStyle = appleGradient;
|
|
521
|
+
|
|
522
|
+
this.ctx.beginPath();
|
|
523
|
+
this.ctx.arc(foodX + appleRadius, foodY + appleRadius, appleRadius, 0, 2 * Math.PI);
|
|
524
|
+
this.ctx.fill();
|
|
525
|
+
|
|
526
|
+
// Apple highlight
|
|
527
|
+
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
|
528
|
+
this.ctx.beginPath();
|
|
529
|
+
this.ctx.arc(foodX + appleRadius * 0.7, foodY + appleRadius * 0.7, appleRadius * 0.3, 0, 2 * Math.PI);
|
|
530
|
+
this.ctx.fill();
|
|
531
|
+
|
|
532
|
+
// Enhanced apple leaf
|
|
533
|
+
this.ctx.fillStyle = '#4caf50';
|
|
534
|
+
this.ctx.beginPath();
|
|
535
|
+
this.ctx.ellipse(foodX + appleRadius + 3, foodY + 3, 4, 8, -0.3, 0, 2 * Math.PI);
|
|
536
|
+
this.ctx.fill();
|
|
537
|
+
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
createSound(frequency, duration, type = 'sine') {
|
|
541
|
+
return { frequency, duration, type };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
playSound(sound) {
|
|
545
|
+
try {
|
|
546
|
+
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
547
|
+
const oscillator = audioContext.createOscillator();
|
|
548
|
+
const gainNode = audioContext.createGain();
|
|
549
|
+
|
|
550
|
+
oscillator.connect(gainNode);
|
|
551
|
+
gainNode.connect(audioContext.destination);
|
|
552
|
+
|
|
553
|
+
oscillator.frequency.setValueAtTime(sound.frequency, audioContext.currentTime);
|
|
554
|
+
oscillator.type = sound.type;
|
|
555
|
+
|
|
556
|
+
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
|
557
|
+
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + sound.duration);
|
|
558
|
+
|
|
559
|
+
oscillator.start(audioContext.currentTime);
|
|
560
|
+
oscillator.stop(audioContext.currentTime + sound.duration);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
// Audio not supported or blocked
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
gameOver() {
|
|
567
|
+
this.gameRunning = false;
|
|
568
|
+
|
|
569
|
+
// Play game over sound
|
|
570
|
+
this.playSound(this.sounds.gameOver);
|
|
571
|
+
|
|
572
|
+
if (this.score > this.highScore) {
|
|
573
|
+
this.highScore = this.score;
|
|
574
|
+
localStorage.setItem('snakeHighScore', this.highScore);
|
|
575
|
+
this.highScoreElement.textContent = this.highScore;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
this.finalScoreElement.textContent = this.score;
|
|
579
|
+
this.gameOverElement.style.display = 'block';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
gameLoop(currentTime = 0) {
|
|
583
|
+
const deltaTime = currentTime - this.lastFrameTime;
|
|
584
|
+
|
|
585
|
+
if (deltaTime >= this.gameSpeed) {
|
|
586
|
+
this.update();
|
|
587
|
+
this.draw();
|
|
588
|
+
this.lastFrameTime = currentTime;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
requestAnimationFrame((time) => this.gameLoop(time));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Start the game when page loads
|
|
596
|
+
window.addEventListener('load', () => {
|
|
597
|
+
new SnakeGame();
|
|
598
|
+
});
|
|
599
|
+
</script>
|
|
600
|
+
</body>
|
|
601
|
+
|
|
602
|
+
</html>
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "luva",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Command line tool for Luvabase",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"prepublishOnly": "npm --no-git-tag-version version patch && npm run build"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@inquirer/prompts": "^5.3.8",
|
|
13
|
+
"commander": "^12.1.0",
|
|
14
|
+
"open": "^10.1.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"luva": "dist/bin.js"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "^5.5.4"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from "commander"
|
|
4
|
+
import { readFileSync } from "fs"
|
|
5
|
+
|
|
6
|
+
const packageJson = JSON.parse(
|
|
7
|
+
readFileSync(new URL("../package.json", import.meta.url)).toString(),
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("luva")
|
|
12
|
+
.description("Command line tool for Luvabase")
|
|
13
|
+
.version(packageJson.version)
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command("hello")
|
|
17
|
+
.description("Say hello")
|
|
18
|
+
.action(async () => {
|
|
19
|
+
console.log("Hello!")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
program.parse()
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"sourceMap": true,
|
|
4
|
+
"module": "preserve",
|
|
5
|
+
"target": "es2022",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"outDir": "./dist"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|