- Created index.html for the main application interface with responsive design and dark mode support. - Added manifest.webmanifest for PWA configuration, including app icons and display settings. - Implemented service worker (sw.js) for offline caching of assets and network-first strategy for versioning. - Introduced version.json to manage app versioning.
443 lines
15 KiB
PHP
443 lines
15 KiB
PHP
<?php /* Simple Tetris clone in a single index.php (HTML + JS). Now with WebAudio sound effects (line clear, drop/lock, game over) + mute toggle. */ ?>
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Vibe Coding Test – Tetris (with SFX)</title>
|
||
<style>
|
||
:root { --bg:#0b0f14; --fg:#e6ebf0; --muted:#8a99a8; --panel:#121922; --accent:#4dd0e1; }
|
||
* { box-sizing:border-box; }
|
||
html,body { height:100%; }
|
||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; background:radial-gradient(1200px 700px at 20% -10%, #15202b 0, #0b0f14 60%); color:var(--fg); display:grid; place-items:center; }
|
||
.wrap { display:grid; grid-template-columns:auto 240px; gap:16px; padding:20px; }
|
||
.panel { background:var(--panel); border:1px solid #1d2631; border-radius:14px; padding:14px; box-shadow:0 10px 25px rgba(0,0,0,.25); }
|
||
canvas { display:block; background:#0c121a; border:1px solid #1d2631; border-radius:12px; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03); }
|
||
h1 { margin:0 0 8px; font-size:18px; letter-spacing:.4px; color:#cfe6ff; display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||
.stats { display:grid; gap:10px; }
|
||
.stat { display:flex; justify-content:space-between; font-variant-numeric:tabular-nums; }
|
||
.controls { margin-top:10px; font-size:13px; color:var(--muted); line-height:1.4; }
|
||
.btns { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
|
||
button { background:#17212b; border:1px solid #243242; color:var(--fg); padding:8px 10px; border-radius:10px; cursor:pointer; }
|
||
button:hover { border-color:#2f455a; }
|
||
.next { margin-top:10px; display:grid; grid-template-columns:repeat(4, 22px); grid-template-rows:repeat(4, 22px); gap:2px; justify-content:flex-start; }
|
||
.cell { width:22px; height:22px; background:#0c121a; border:1px solid #1a2532; border-radius:4px; }
|
||
.badge { display:inline-block; background:#13202a; border:1px solid #2a404f; padding:2px 6px; border-radius:6px; font-size:12px; color:#bfe9ff; }
|
||
.footer { margin-top:8px; font-size:12px; color:#7f8fa1; }
|
||
.muted { opacity:.6; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="panel">
|
||
<canvas id="board" width="300" height="600" aria-label="Tetris board" role="img"></canvas>
|
||
</div>
|
||
<div class="panel" style="width:240px">
|
||
<h1>
|
||
<span>TETRIS <span class="badge">vanilla JS</span></span>
|
||
<button id="btn-sound" title="Toggle sound">🔊</button>
|
||
</h1>
|
||
<div class="stats">
|
||
<div class="stat"><span>Score</span><strong id="score">0</strong></div>
|
||
<div class="stat"><span>Lines</span><strong id="lines">0</strong></div>
|
||
<div class="stat"><span>Level</span><strong id="level">1</strong></div>
|
||
</div>
|
||
<div style="margin-top:10px">
|
||
<div style="font-size:13px; color:#9fb3c6; margin-bottom:6px">Next</div>
|
||
<div id="next" class="next" aria-label="Next tetromino preview"></div>
|
||
</div>
|
||
<div class="btns">
|
||
<button id="btn-pause" title="P">Pause</button>
|
||
<button id="btn-restart" title="R">Restart</button>
|
||
</div>
|
||
<div class="controls">
|
||
<div>← → move, ↓ soft drop</div>
|
||
<div>↑ rotate, Space hard drop</div>
|
||
<div>P pause, R restart</div>
|
||
<div style="margin-top:6px; color:#9fb3c6">Tip: Some browsers need a key press to enable audio.</div>
|
||
</div>
|
||
<div class="footer">© <?php echo date('Y'); ?> VIBING test</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
// ====== Config ======
|
||
const COLS = 10, ROWS = 20, SIZE = 30; // canvas cell size
|
||
const GRAVITY_BASE_MS = 900; // level 1 drop interval
|
||
const LEVEL_ACCEL = 0.88; // per level speed multiplier
|
||
|
||
// Colors per piece type
|
||
const COLORS = {
|
||
I: '#66d9ff', J: '#85a5ff', L: '#ffc98b', O: '#f7f58a', S: '#9cff9c', T: '#e6a8ff', Z: '#ff9aae'
|
||
};
|
||
|
||
// Tetromino rotation states (4x4 matrices)
|
||
const SHAPES = {
|
||
I: [
|
||
[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],
|
||
[[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]],
|
||
[[0,0,0,0],[0,0,0,0],[1,1,1,1],[0,0,0,0]],
|
||
[[0,1,0,0],[0,1,0,0],[0,1,0,0],[0,1,0,0]]
|
||
],
|
||
J: [
|
||
[[1,0,0],[1,1,1],[0,0,0]],
|
||
[[0,1,1],[0,1,0],[0,1,0]],
|
||
[[0,0,0],[1,1,1],[0,0,1]],
|
||
[[0,1,0],[0,1,0],[1,1,0]]
|
||
],
|
||
L: [
|
||
[[0,0,1],[1,1,1],[0,0,0]],
|
||
[[0,1,0],[0,1,0],[0,1,1]],
|
||
[[0,0,0],[1,1,1],[1,0,0]],
|
||
[[1,1,0],[0,1,0],[0,1,0]]
|
||
],
|
||
O: [
|
||
[[1,1],[1,1]],
|
||
[[1,1],[1,1]],
|
||
[[1,1],[1,1]],
|
||
[[1,1],[1,1]]
|
||
],
|
||
S: [
|
||
[[0,1,1],[1,1,0],[0,0,0]],
|
||
[[0,1,0],[0,1,1],[0,0,1]],
|
||
[[0,0,0],[0,1,1],[1,1,0]],
|
||
[[1,0,0],[1,1,0],[0,1,0]]
|
||
],
|
||
T: [
|
||
[[0,1,0],[1,1,1],[0,0,0]],
|
||
[[0,1,0],[0,1,1],[0,1,0]],
|
||
[[0,0,0],[1,1,1],[0,1,0]],
|
||
[[0,1,0],[1,1,0],[0,1,0]]
|
||
],
|
||
Z: [
|
||
[[1,1,0],[0,1,1],[0,0,0]],
|
||
[[0,0,1],[0,1,1],[0,1,0]],
|
||
[[0,0,0],[1,1,0],[0,1,1]],
|
||
[[0,1,0],[1,1,0],[1,0,0]]
|
||
]
|
||
};
|
||
|
||
// ====== Helpers ======
|
||
const $ = sel => document.querySelector(sel);
|
||
const board = $('#board');
|
||
const ctx = board.getContext('2d');
|
||
board.width = COLS * SIZE; board.height = ROWS * SIZE;
|
||
|
||
const ui = {
|
||
score: $('#score'), lines: $('#lines'), level: $('#level'),
|
||
next: $('#next'), pauseBtn: $('#btn-pause'), restartBtn: $('#btn-restart'), soundBtn: $('#btn-sound')
|
||
};
|
||
|
||
function emptyMatrix(w, h, v=0) { return Array.from({length:h}, () => Array(w).fill(v)); }
|
||
|
||
// 7-bag randomizer
|
||
class Bag {
|
||
constructor(){ this.bag = []; }
|
||
next(){
|
||
if (this.bag.length === 0) this.bag = shuffle(Object.keys(SHAPES));
|
||
return this.bag.pop();
|
||
}
|
||
}
|
||
function shuffle(a){ for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; }
|
||
|
||
// Piece class
|
||
class Piece {
|
||
constructor(type){
|
||
this.type = type;
|
||
this.rot = 0;
|
||
this.shape = SHAPES[type][0];
|
||
this.x = Math.floor((COLS - this.shape[0].length)/2);
|
||
this.y = -this.shape.length; // spawn above
|
||
}
|
||
rotate(dir){ // dir: +1 cw, -1 ccw
|
||
const oldRot = this.rot;
|
||
this.rot = (this.rot + dir + 4) % 4;
|
||
this.shape = SHAPES[this.type][this.rot];
|
||
// simple wall-kick: try offsets
|
||
const kicks = [0, -1, 1, -2, 2];
|
||
for (const dx of kicks){ if (!collide(boardState, this, this.x+dx, this.y)) { this.x += dx; sfx.tick(); return true; } }
|
||
// revert
|
||
this.rot = oldRot; this.shape = SHAPES[this.type][this.rot];
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ====== Audio (WebAudio synth) ======
|
||
class SFX {
|
||
constructor(){
|
||
this.enabled = true;
|
||
this.ctx = null;
|
||
this.started = false;
|
||
const gesture = () => { this.start(); window.removeEventListener('keydown', gesture); window.removeEventListener('pointerdown', gesture); };
|
||
window.addEventListener('keydown', gesture);
|
||
window.addEventListener('pointerdown', gesture);
|
||
}
|
||
start(){
|
||
if (this.started) return;
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
if (!AC) return; // no audio support
|
||
this.ctx = new AC();
|
||
this.started = true;
|
||
}
|
||
toggle(){ this.enabled = !this.enabled; updateSoundButton(); }
|
||
// tiny helper to make beeps
|
||
beep(freq=440, dur=0.08, type='square', gain=0.03, when=0){
|
||
if (!this.enabled || !this.ctx) return;
|
||
const t = this.ctx.currentTime + when;
|
||
const o = this.ctx.createOscillator();
|
||
const g = this.ctx.createGain();
|
||
o.type = type; o.frequency.setValueAtTime(freq, t);
|
||
g.gain.setValueAtTime(0.0001, t);
|
||
g.gain.exponentialRampToValueAtTime(gain, t+0.005);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||
o.connect(g).connect(this.ctx.destination);
|
||
o.start(t);
|
||
o.stop(t + dur + 0.02);
|
||
}
|
||
drop(){ this.beep(220, 0.06, 'sawtooth', 0.04); }
|
||
lock(){ this.beep(180, 0.07, 'triangle', 0.035); }
|
||
line(n){
|
||
const base = 520; // cheerful arpeggio
|
||
for (let i=0;i<n;i++) this.beep(base + i*120, 0.08, 'square', 0.04, i*0.05);
|
||
}
|
||
gameover(){
|
||
[440, 392, 349, 329].forEach((f,i)=> this.beep(f, 0.18, 'sawtooth', 0.035, i*0.12));
|
||
}
|
||
tick(){ this.beep(700, 0.03, 'square', 0.02); } // rotate/move
|
||
}
|
||
const sfx = new SFX();
|
||
|
||
function updateSoundButton(){
|
||
if (!ui.soundBtn) return;
|
||
ui.soundBtn.textContent = sfx.enabled ? '🔊' : '🔈';
|
||
ui.soundBtn.classList.toggle('muted', !sfx.enabled);
|
||
}
|
||
|
||
// ====== Game state ======
|
||
let boardState = emptyMatrix(COLS, ROWS, 0);
|
||
let bag = new Bag();
|
||
let current = new Piece(bag.next());
|
||
let nextPieceType = bag.next();
|
||
let score = 0, lines = 0, level = 1;
|
||
let dropTimer = 0, dropInterval = gravityFor(level), lastTime = 0;
|
||
let paused = false, gameOver = false;
|
||
|
||
drawNextPreview();
|
||
updateUI();
|
||
requestAnimationFrame(loop);
|
||
|
||
// ====== Core ======
|
||
function loop(time=0){
|
||
const dt = time - lastTime; lastTime = time;
|
||
if (!paused && !gameOver){
|
||
dropTimer += dt;
|
||
if (dropTimer >= dropInterval){
|
||
dropTimer = 0; softDrop(true);
|
||
}
|
||
}
|
||
render();
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
function gravityFor(level){ return GRAVITY_BASE_MS * Math.pow(LEVEL_ACCEL, Math.max(0, level-1)); }
|
||
|
||
function collide(mat, piece, ox = piece.x, oy = piece.y){
|
||
const s = piece.shape;
|
||
for (let y=0; y<s.length; y++){
|
||
for (let x=0; x<s[y].length; x++){
|
||
if (!s[y][x]) continue;
|
||
const px = ox + x, py = oy + y;
|
||
if (px < 0 || px >= COLS || py >= ROWS) return true;
|
||
if (py >= 0 && mat[py][px]) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function lockPiece(){
|
||
const s = current.shape;
|
||
for (let y=0; y<s.length; y++){
|
||
for (let x=0; x<s[y].length; x++){
|
||
if (s[y][x]){
|
||
const px = current.x + x, py = current.y + y;
|
||
if (py < 0){ // topped out
|
||
gameOver = true; paused = false; sfx.gameover();
|
||
return;
|
||
}
|
||
boardState[py][px] = current.type;
|
||
}
|
||
}
|
||
}
|
||
sfx.lock();
|
||
const cleared = clearLines();
|
||
if (cleared > 0){
|
||
lines += cleared;
|
||
score += [0, 100, 300, 500, 800][cleared] * level;
|
||
level = 1 + Math.floor(lines / 10);
|
||
dropInterval = gravityFor(level);
|
||
sfx.line(cleared);
|
||
}
|
||
current = new Piece(nextPieceType);
|
||
nextPieceType = bag.next();
|
||
drawNextPreview();
|
||
}
|
||
|
||
function clearLines(){
|
||
let cleared = 0;
|
||
for (let y = ROWS-1; y >= 0; y--){
|
||
if (boardState[y].every(v => v)){
|
||
boardState.splice(y,1);
|
||
boardState.unshift(Array(COLS).fill(0));
|
||
cleared++; y++;
|
||
}
|
||
}
|
||
return cleared;
|
||
}
|
||
|
||
function softDrop(fromGravity=false){
|
||
if (!collide(boardState, current, current.x, current.y+1)){
|
||
current.y++;
|
||
if (!fromGravity) sfx.drop(); // manual soft drop feedback
|
||
} else {
|
||
lockPiece();
|
||
}
|
||
updateUI();
|
||
}
|
||
|
||
function hardDrop(){
|
||
let dist = 0;
|
||
while (!collide(boardState, current, current.x, current.y+1)){
|
||
current.y++; dist++;
|
||
}
|
||
score += 2 * dist; // small bonus
|
||
sfx.drop();
|
||
lockPiece();
|
||
updateUI();
|
||
}
|
||
|
||
function move(dx){
|
||
if (!collide(boardState, current, current.x+dx, current.y)) { current.x += dx; sfx.tick(); }
|
||
}
|
||
|
||
// ====== Rendering ======
|
||
function render(){
|
||
ctx.clearRect(0,0,board.width,board.height);
|
||
// grid (subtle)
|
||
ctx.globalAlpha = 0.05;
|
||
for(let x=0;x<COLS;x++){
|
||
for(let y=0;y<ROWS;y++){
|
||
ctx.strokeRect(x*SIZE, y*SIZE, SIZE, SIZE);
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
|
||
// draw board
|
||
for (let y=0; y<ROWS; y++){
|
||
for (let x=0; x<COLS; x++){
|
||
const t = boardState[y][x];
|
||
if (!t) continue;
|
||
drawCell(x, y, COLORS[t]);
|
||
}
|
||
}
|
||
|
||
// draw current piece
|
||
const s = current.shape;
|
||
for (let y=0; y<s.length; y++){
|
||
for (let x=0; x<s[y].length; x++){
|
||
if (!s[y][x]) continue;
|
||
const px = current.x + x, py = current.y + y;
|
||
if (py >= 0) drawCell(px, py, COLORS[current.type]);
|
||
}
|
||
}
|
||
|
||
if (paused) overlayText('PAUSED');
|
||
if (gameOver) overlayText('GAME OVER');
|
||
}
|
||
|
||
function overlayText(text){
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||
ctx.fillRect(0,0,board.width,board.height);
|
||
ctx.fillStyle = '#e6ebf0';
|
||
ctx.font = 'bold 28px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(text, board.width/2, board.height/2);
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawCell(x, y, color){
|
||
const px = x*SIZE, py = y*SIZE;
|
||
// base
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(px+1, py+1, SIZE-2, SIZE-2);
|
||
// glossy inset
|
||
ctx.fillStyle = 'rgba(255,255,255,.15)';
|
||
ctx.fillRect(px+3, py+3, SIZE-6, Math.floor((SIZE-6)/2));
|
||
// border
|
||
ctx.strokeStyle = 'rgba(0,0,0,.35)';
|
||
ctx.strokeRect(px+0.5, py+0.5, SIZE-1, SIZE-1);
|
||
}
|
||
|
||
function drawNextPreview(){
|
||
ui.next.innerHTML = '';
|
||
const grid = Array.from({length:16}, () => {
|
||
const d = document.createElement('div'); d.className = 'cell'; return d; });
|
||
grid.forEach(cell => ui.next.appendChild(cell));
|
||
const shape = SHAPES[nextPieceType][0];
|
||
// center in 4x4
|
||
const offX = Math.floor((4 - shape[0].length)/2);
|
||
const offY = Math.floor((4 - shape.length)/2);
|
||
for (let y=0;y<shape.length;y++){
|
||
for (let x=0;x<shape[y].length;x++){
|
||
if (shape[y][x]){
|
||
const idx = (offY+y)*4 + (offX+x);
|
||
grid[idx].style.background = COLORS[nextPieceType];
|
||
grid[idx].style.borderColor = '#2a3b4f';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateUI(){
|
||
ui.score.textContent = score;
|
||
ui.lines.textContent = lines;
|
||
ui.level.textContent = level;
|
||
}
|
||
|
||
// ====== Input ======
|
||
window.addEventListener('keydown', (e) => {
|
||
if (gameOver){ if (e.key.toLowerCase()==='r'){ restart(); } return; }
|
||
switch (e.key) {
|
||
case 'ArrowLeft': move(-1); break;
|
||
case 'ArrowRight': move(+1); break;
|
||
case 'ArrowDown': softDrop(); break;
|
||
case 'ArrowUp': current.rotate(+1); break;
|
||
case ' ': e.preventDefault(); hardDrop(); break;
|
||
case 'p': case 'P': togglePause(); break;
|
||
case 'r': case 'R': restart(); break;
|
||
}
|
||
});
|
||
|
||
ui.pauseBtn.addEventListener('click', togglePause);
|
||
ui.restartBtn.addEventListener('click', restart);
|
||
ui.soundBtn.addEventListener('click', () => { sfx.toggle(); });
|
||
|
||
function togglePause(){ if (gameOver) return; paused = !paused; }
|
||
|
||
function restart(){
|
||
boardState = emptyMatrix(COLS, ROWS, 0);
|
||
bag = new Bag();
|
||
current = new Piece(bag.next());
|
||
nextPieceType = bag.next();
|
||
score = 0; lines = 0; level = 1; dropInterval = gravityFor(level); dropTimer = 0; lastTime = 0;
|
||
gameOver = false; paused = false;
|
||
drawNextPreview(); updateUI();
|
||
}
|
||
|
||
// initialize sound button state
|
||
updateSoundButton();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|