feat: Add WFW-Aushang web app with PWA support, offline caching, and dark mode
- 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.
This commit is contained in:
BIN
ittybittytetris/icon.png
Normal file
BIN
ittybittytetris/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
443
ittybittytetris/index.php
Normal file
443
ittybittytetris/index.php
Normal file
@@ -0,0 +1,443 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user