🎮ArcadeLab

Pixel Sandbox - 2D Sandbox Game

by SonicLegend13
907 lines36.9 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Pixel Sandbox - 2D Sandbox Game</title>
<style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
        overflow: hidden;
        background: #000;
        font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
        touch-action: none;
        user-select: none;
        -webkit-user-select: none;
    }
    #game {
        display: block;
        width: 100vw;
        height: 100vh;
        image-rendering: pixelated;
        image-rendering: crisp-edges;
        cursor: crosshair;
    }
    #ui {
        position: fixed;
        top: 10px;
        left: 10px;
        right: 10px;
        display: flex;
        gap: 8px;
        flex-wrap: wrap;
        z-index: 10;
        pointer-events: none;
    }
    #ui > * { pointer-events: all; }
    #ui button {
        background: rgba(0,0,0,0.75);
        color: #fff;
        border: 1px solid rgba(255,255,255,0.2);
        padding: 8px 14px;
        border-radius: 8px;
        font-size: 13px;
        font-weight: 600;
        backdrop-filter: blur(8px);
        cursor: pointer;
        transition: all 0.2s;
    }
    #ui button:hover { background: rgba(30,30,30,0.9); transform: translateY(-1px); }
    #ui button:active { transform: translateY(0); }
    #modeLabel {
        color: #fff;
        background: rgba(0,0,0,0.75);
        padding: 8px 14px;
        border-radius: 8px;
        font-size: 13px;
        font-weight: 600;
        backdrop-filter: blur(8px);
        border: 1px solid rgba(255,255,255,0.2);
    }
    #hotbar {
        position: fixed;
        bottom: 20px;
        left: 50%;
        transform: translateX(-50%);
        display: flex;
        gap: 4px;
        background: rgba(0,0,0,0.75);
        padding: 6px;
        border-radius: 12px;
        backdrop-filter: blur(10px);
        border: 1px solid rgba(255,255,255,0.15);
        z-index: 10;
    }
    .slot {
        width: 52px;
        height: 52px;
        border: 2px solid #333;
        border-radius: 8px;
        cursor: pointer;
        position: relative;
        background: #1a1a1a;
        transition: all 0.15s;
        overflow: hidden;
    }
    .slot:hover { border-color: #666; }
    .slot.selected {
        border-color: #fff;
        transform: translateY(-6px);
        box-shadow: 0 6px 20px rgba(255,255,255,0.25), inset 0 0 0 1px rgba(255,255,255,0.5);
        background: #2a2a2a;
    }
    .slot .icon {
        width: 100%;
        height: 100%;
        image-rendering: pixelated;
    }
    .slot span {
        position: absolute;
        bottom: 2px;
        right: 5px;
        color: #fff;
        font-size: 11px;
        font-weight: bold;
        text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
        pointer-events: none;
    }
    .bars {
        position: fixed;
        bottom: 86px;
        left: 50%;
        transform: translateX(-50%);
        display: flex;
        flex-direction: column;
        gap: 6px;
        z-index: 10;
        pointer-events: none;
    }
    .bar {
        width: 208px;
        height: 18px;
        background: rgba(0,0,0,0.8);
        border-radius: 9px;
        overflow: hidden;
        border: 2px solid rgba(255,255,255,0.2);
        display: none;
        backdrop-filter: blur(4px);
    }
    .bar .fill {
        height: 100%;
        width: 100%;
        transition: width 0.3s ease;
        position: relative;
    }
    #healthBar .fill {
        background: linear-gradient(90deg, #e74c3c, #c0392b);
        box-shadow: inset 0 2px 4px rgba(255,255,255,0.2);
    }
    #hungerBar .fill {
        background: linear-gradient(90deg, #d4a574, #b97a56);
        box-shadow: inset 0 2px 4px rgba(255,255,255,0.2);
    }
    .bar.active { display: block; }
    #tutorial {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: rgba(10,10,20,0.95);
        color: #fff;
        padding: 32px 40px;
        border-radius: 20px;
        text-align: center;
        max-width: 90%;
        width: 420px;
        z-index: 100;
        backdrop-filter: blur(20px);
        border: 1px solid rgba(255,255,255,0.15);
        box-shadow: 0 20px 60px rgba(0,0,0,0.8);
    }
    #tutorial h2 {
        margin: 0 0 16px;
        font-size: 28px;
        background: linear-gradient(90deg, #4CAF50, #8BC34A);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        background-clip: text;
    }
    #tutorial p {
        margin: 10px 0;
        opacity: 0.9;
        font-size: 15px;
        line-height: 1.5;
    }
    #tutorial .controls {
        background: rgba(255,255,255,0.05);
        padding: 14px;
        border-radius: 10px;
        margin: 16px 0;
        border: 1px solid rgba(255,255,255,0.1);
    }
    #tutorial button {
        margin-top: 20px;
        padding: 12px 32px;
        background: linear-gradient(90deg, #4CAF50, #45a049);
        border: none;
        color: #fff;
        border-radius: 10px;
        font-size: 16px;
        font-weight: 600;
        cursor: pointer;
        transition: all 0.2s;
        box-shadow: 0 4px 15px rgba(76,175,80,0.3);
    }
    #tutorial button:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(76,175,80,0.4); }
    #toast {
        position: fixed;
        top: 70px;
        left: 50%;
        transform: translateX(-50%) translateY(-20px);
        background: rgba(0,0,0,0.9);
        color: #fff;
        padding: 12px 24px;
        border-radius: 8px;
        font-weight: 600;
        opacity: 0;
        transition: all 0.3s;
        z-index: 200;
        pointer-events: none;
        border: 1px solid rgba(255,255,255,0.2);
    }
    #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
    #mobileControls {
        position: fixed;
        bottom: 0;
        left: 0;
        right: 0;
        height: 200px;
        pointer-events: none;
        z-index: 20;
        display: none;
    }
    .joystick {
        position: absolute;
        left: 25px;
        bottom: 25px;
        width: 120px;
        height: 120px;
        background: rgba(255,255,255,0.08);
        border: 2px solid rgba(255,255,255,0.25);
        border-radius: 50%;
        pointer-events: all;
        backdrop-filter: blur(4px);
    }
    .stick {
        position: absolute;
        width: 54px;
        height: 54px;
        background: rgba(255,255,255,0.4);
        border: 2px solid rgba(255,255,255,0.6);
        border-radius: 50%;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        transition: transform 0.05s;
    }
    .mbtn {
        position: absolute;
        width: 72px;
        height: 72px;
        background: rgba(255,255,255,0.12);
        border: 2px solid rgba(255,255,255,0.35);
        border-radius: 50%;
        pointer-events: all;
        color: #fff;
        font-weight: bold;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 11px;
        backdrop-filter: blur(6px);
        text-align: center;
        line-height: 1.2;
        user-select: none;
    }
    .mbtn:active { background: rgba(255,255,255,0.25); transform: scale(0.95); }
    #jumpBtn { right: 25px; bottom: 110px; }
    #actionBtn { right: 105px; bottom: 25px; width: 84px; height: 84px; font-size: 13px; }
    #modeBtnM { right: 25px; bottom: 25px; }
    @media (max-width: 768px) {
        #mobileControls { display: block; }
        #hotbar { bottom: 210px; transform: translateX(-50%) scale(0.9); }
        .bars { bottom: 280px; }
        #ui { top: 8px; left: 8px; }
        #ui button, #modeLabel { padding: 7px 11px; font-size: 12px; }
        .slot { width: 46px; height: 46px; }
    }
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="ui">
    <button id="saveBtn">Save World</button>
    <button id="loadBtn">Load World</button>
    <button id="resetBtn">Reset</button>
    <button id="modeBtn">Creative Mode</button>
    <div id="modeLabel">Creative</div>
</div>

<div class="bars">
    <div class="bar" id="hungerBar"><div class="fill"></div></div>
    <div class="bar" id="healthBar"><div class="fill"></div></div>
</div>

<div id="hotbar"></div>

<div id="tutorial">
    <h2>PIXEL SANDBOX</h2>
    <p>An infinite 2D world ready to explore!</p>
    <div class="controls">
        <p><strong>Left Click</strong> = break &nbsp; • &nbsp; <strong>Right Click</strong> = place</p>
        <p><strong>WASD / Arrows</strong> = move &nbsp; • &nbsp; <strong>Space</strong> = jump</p>
        <p><strong>1-9</strong> = select block &nbsp; • &nbsp; <strong>Scroll</strong> = switch</p>
        <p><strong>S</strong> = crouch</p>
    </div>
    <p style="opacity:0.7; font-size:13px">Dig, build, and survive!</p>
    <button id="startBtn">Start Playing</button>
</div>

<div id="toast"></div>

<div id="mobileControls">
    <div class="joystick" id="joystick"><div class="stick" id="stick"></div></div>
    <div class="mbtn" id="jumpBtn">JUMP</div>
    <div class="mbtn" id="actionBtn">BREAK</div>
    <div class="mbtn" id="modeBtnM">PLACE</div>
</div>

<script>
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;

let width = window.innerWidth, height = window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.scale(dpr, dpr);

const BLOCK = 16;
const SCALE = width < 768 ? 1.75 : 2.2;
const TS = BLOCK * SCALE;
const WORLD_H = 140;
const VIEW_W = Math.ceil(width / TS) + 4;
const VIEW_H = Math.ceil(height / TS) + 4;

const BLOCKS = { AIR:0, GRASS:1, DIRT:2, STONE:3, PLANK:4, SAND:5, GLASS:6, LEAVES:7, WATER:8, LAVA:9, LOG:10, COAL:11, IRON:12, GOLD:13, FLOWER:14, TALLGRASS:15, BEDROCK:16, GRAVEL:17 };
const HARDNESS = {0:0,1:0.5,2:0.4,3:1.8,4:1,5:0.4,6:0.3,7:0.2,8:0,9:0,10:1.2,11:2,12:2.5,13:3,14:0.1,15:0.1,16:999,17:0.5};
const isSolid = id => id!==0 && id!==8 && id!==9 && id!==14 && id!==15;
const isFalling = id => id===5 || id===17;

const seed = 1337;
function rand2d(x,y){ const s = Math.sin(x*127.1 + y*311.7 + seed)*43758.5453; return s-Math.floor(s); }
function smoothNoise(x,y){ const ix=Math.floor(x),iy=Math.floor(y),fx=x-ix,fy=y-iy; const a=rand2d(ix,iy),b=rand2d(ix+1,iy),c=rand2d(ix,iy+1),d=rand2d(ix+1,iy+1); const ux=fx*fx*(3-2*fx),uy=fy*fy*(3-2*fy); return a*(1-ux)*(1-uy)+b*ux*(1-uy)+c*(1-ux)*uy+d*ux*uy; }
function perlin(x,y,o=3){ let v=0,amp=1,freq=1,max=0; for(let i=0;i<o;i++){ v+=smoothNoise(x*freq,y*freq)*amp; max+=amp; amp*=0.5; freq*=2; } return v/max; }

const worldMod = new Map();
const worldGen = new Map();

function getGen(x,y){
    const key = x+','+y;
    if(worldGen.has(key)) return worldGen.get(key);
    let id = BLOCKS.AIR;
    const surface = 70 + Math.floor((perlin(x*0.012,0,4)-0.5)*22);
    const waterLevel = 68;
    
    if(y > WORLD_H-3) id = BLOCKS.BEDROCK;
    else if(y === surface){
        if(y > waterLevel-2) id = BLOCKS.SAND;
        else id = BLOCKS.GRASS;
    }
    else if(y > surface && y < surface+4){
        id = (y > waterLevel-1) ? BLOCKS.SAND : BLOCKS.DIRT;
    }
    else if(y >= surface+4 && y < WORLD_H-3){
        const cave = perlin(x*0.07, y*0.07, 3);
        if(cave > 0.58) id = BLOCKS.AIR;
        else {
            id = BLOCKS.STONE;
            const r = rand2d(x,y);
            if(y > 85 && r < 0.07) id = BLOCKS.COAL;
            else if(y > 95 && r < 0.045) id = BLOCKS.IRON;
            else if(y > 110 && r < 0.025) id = BLOCKS.GOLD;
            else if(y > 100 && rand2d(x*2,y*3) < 0.03) id = BLOCKS.GRAVEL;
        }
    }
    if(y < surface && y >= waterLevel && surface < waterLevel){
        id = BLOCKS.WATER;
    }
    // trees
    if(id===BLOCKS.GRASS){
        const treeChance = rand2d(x, 999);
        if(treeChance < 0.018 && y < waterLevel){
            // tree will be handled in getBlock for structure
        }
        else if(rand2d(x*1.3, y) < 0.06) id = BLOCKS.FLOWER;
        else if(rand2d(x*0.7, y+5) < 0.12) id = BLOCKS.TALLGRASS;
    }
    // tree structure check
    const tx = Math.floor(x/1)*1;
    if(rand2d(tx,999) < 0.018){
        const th = 4 + Math.floor(rand2d(tx,555)*2);
        const treeSurf = 70 + Math.floor((perlin(tx*0.012,0,4)-0.5)*22);
        if(x===tx && y>=treeSurf-th && y<treeSurf) id = BLOCKS.LOG;
        const dx = Math.abs(x-tx), dy = y-(treeSurf-th);
        if(dx<=2 && dy>=-1 && dy<=1 && !(x===tx && y<treeSurf)){
            if(dx+Math.abs(dy) < 3.5) id = BLOCKS.LEAVES;
        }
    }
    
    worldGen.set(key,id);
    return id;
}

function getBlock(x,y){
    if(y<0 || y>=WORLD_H) return BLOCKS.AIR;
    const key = x+','+y;
    if(worldMod.has(key)) return worldMod.get(key);
    return getGen(x,y);
}
function setBlock(x,y,id){
    const key = x+','+y;
    const gen = getGen(x,y);
    if(id===gen) worldMod.delete(key);
    else worldMod.set(key, id);
}

const player = { x:0, y:0, vx:0, vy:0, w:12, h:26, onGround:false, facing:1 };
function spawnPlayer(){
    const sx=0;
    const sy = 70 + Math.floor((perlin(sx*0.012,0,4)-0.5)*22);
    player.x = sx*BLOCK - 6;
    player.y = (sy-5)*BLOCK;
    player.vx=0; player.vy=0;
}
spawnPlayer();

let camX=0, camY=0;
let time=8000;
const hotbar = [1,2,3,4,5,6,7,8,9];
let selected=0;
let gameMode='creative';
let health=20, hunger=20;
let hungerTimer=0;

const inputs = { left:false, right:false, up:false, down:false };
const mouse = { x:0, y:0, downL:false, downR:false, worldX:0, worldY:0 };
let breaking=null, breakProgress=0;

const particles=[];
const clouds=[];
for(let i=0;i<12;i++) clouds.push({x:Math.random()*4000-2000, y:20+Math.random()*60, s:2+Math.random()*3, spd:0.2+Math.random()*0.3});

const stars=[];
for(let i=0;i<180;i++) stars.push({x:Math.random(), y:Math.random()*0.6, b:0.3+Math.random()*0.7, t:Math.random()*6.28});

let audioCtx=null;
function initAudio(){ if(!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }
function playSound(f,type='sine',t=0.08,v=0.12){
    if(!audioCtx) return;
    const o=audioCtx.createOscillator(), g=audioCtx.createGain();
    o.type=type; o.frequency.value=f+Math.random()*20;
    g.gain.setValueAtTime(v,audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime+t);
    o.connect(g); g.connect(audioCtx.destination);
    o.start(); o.stop(audioCtx.currentTime+t);
}

function showToast(msg){
    const t=document.getElementById('toast');
    t.textContent=msg; t.classList.add('show');
    setTimeout(()=>t.classList.remove('show'),2000);
}

function addParticle(x,y,color,blockId){
    for(let i=0;i<8;i++){
        particles.push({x:x+TS/2,y:y+TS/2,vx:(Math.random()-0.5)*4,vx0:(Math.random()-0.5)*4,vy:Math.random()*-3-1,life:30+Math.random()*20,color,sz:2+Math.random()*2});
    }
}

function collide(px,py,w,h){
    const left=Math.floor(px/BLOCK), right=Math.floor((px+w-0.1)/BLOCK);
    const top=Math.floor(py/BLOCK), bot=Math.floor((py+h-0.1)/BLOCK);
    for(let y=top;y<=bot;y++) for(let x=left;x<=right;x++) if(isSolid(getBlock(x,y))) return true;
    return false;
}

const hotbarEl=document.getElementById('hotbar');
function buildHotbar(){
    hotbarEl.innerHTML='';
    const colors={1:'linear-gradient(to bottom,#4e9c42 0% 32%,#8B5A2B 32%)',2:'#8B5A2B',3:'#9b9b9b',4:'#c49a6c',5:'#e2c98a',6:'rgba(160,216,239,0.7)',7:'#3a8c3e',8:'#2e6bcc',9:'#d04525',10:'#6d4c41',11:'#555',12:'#b5855c',13:'#d4af37'};
    hotbar.forEach((id,i)=>{
        const s=document.createElement('div'); s.className='slot'+(i===selected?' selected':''); s.dataset.i=i;
        const ic=document.createElement('div'); ic.className='icon'; ic.style.background=colors[id]||'#333';
        if(id===4){ ic.style.backgroundImage='repeating-linear-gradient(#c49a6c,#c49a6c 8px,#b3895f 8px,#b3895f 9px)'; }
        if(id===6){ ic.style.border='2px solid #7fc0d9'; ic.style.boxSizing='border-box'; }
        if(id===10){ ic.style.background='linear-gradient(90deg,#5d4037 0 30%,#6d4c41 30% 70%,#5d4037 70%)'; }
        const num=document.createElement('span'); num.textContent=i+1;
        s.appendChild(ic); s.appendChild(num);
        s.onclick=()=>{selected=i; updateHotbar(); initAudio();};
        hotbarEl.appendChild(s);
    });
}
function updateHotbar(){ document.querySelectorAll('.slot').forEach((s,i)=>s.classList.toggle('selected',i===selected)); }
buildHotbar();

function update(dt){
    time=(time+0.4)%24000;
    // input
    let tx=0;
    if(inputs.left) tx-=1;
    if(inputs.right) tx+=1;
    player.facing = tx!==0 ? (tx>0?1:-1) : player.facing;
    const speed = inputs.down ? 1.8 : 3.2;
    player.vx += (tx*speed - player.vx)*0.3;
    
    const inWater = getBlock(Math.floor((player.x+player.w/2)/BLOCK), Math.floor((player.y+player.h*0.6)/BLOCK))===BLOCKS.WATER;
    const inLava = getBlock(Math.floor((player.x+player.w/2)/BLOCK), Math.floor((player.y+player.h*0.6)/BLOCK))===BLOCKS.LAVA;
    
    if(inputs.up){
        if(inWater){ player.vy = -2.2; }
        else if(player.onGround){ player.vy = -8.8; player.onGround=false; playSound(520,'square',0.06,0.08); }
    }
    player.vy += inWater ? 0.18 : 0.46;
    if(inWater) player.vy *= 0.85;
    player.vy = Math.max(-12, Math.min(12, player.vy));
    
    const prevVy=player.vy;
    // horizontal
    let nx = player.x + player.vx;
    if(!collide(nx, player.y, player.w, player.h)) player.x=nx;
    else player.vx=0;
    // vertical
    let ny = player.y + player.vy;
    player.onGround=false;
    if(!collide(player.x, ny, player.w, player.h)) player.y=ny;
    else {
        if(player.vy>0){ player.onGround=true; if(gameMode==='survival' && prevVy>11){ const dmg=Math.floor((prevVy-10)/2.5); health=Math.max(0,health-dmg); updateBars(); playSound(180,'sawtooth',0.15,0.2); if(health<=0) respawn(); } }
        player.vy=0;
    }
    // clamp fall void
    if(player.y > WORLD_H*BLOCK) { if(gameMode==='survival'){health=0;respawn();} else {player.y=0; player.vy=0;} }
    
    if(inLava && gameMode==='survival'){ health=Math.max(0,health-0.15); updateBars(); if(health<=0) respawn(); }
    
    // camera
    const vw = width/SCALE, vh=height/SCALE;
    const targetCamX = player.x + player.w/2 - vw/2;
    const targetCamY = player.y + player.h/2 - vh/2;
    camX += (targetCamX - camX)*0.12;
    camY += (targetCamY - camY)*0.12;
    camY = Math.max(-20, Math.min(camY, WORLD_H*BLOCK - vh + 20));
    
    // mouse world
    mouse.worldX = camX + mouse.x/SCALE;
    mouse.worldY = camY + mouse.y/SCALE;
    const bx=Math.floor(mouse.worldX/BLOCK), by=Math.floor(mouse.worldY/BLOCK);
    const dist = Math.hypot((bx*BLOCK+BLOCK/2)-(player.x+player.w/2), (by*BLOCK+BLOCK/2)-(player.y+player.h/2));
    const inReach = dist < 90;
    
    // breaking
    if(mouse.downL && inReach){
        const bid=getBlock(bx,by);
        if(bid!==0 && bid!==BLOCKS.BEDROCK){
            if(!breaking || breaking.x!==bx || breaking.y!==by){ breaking={x:bx,y:by}; breakProgress=0; }
            const hard=HARDNESS[bid]||1;
            breakProgress += gameMode==='creative' ? 1 : dt/(hard*0.8);
            if(breakProgress>=1){
                setBlock(bx,by,BLOCKS.AIR);
                addParticle(bx*BLOCK, by*BLOCK, '#aaa', bid);
                playSound(240+Math.random()*80,'square',0.07,0.1);
                breaking=null; breakProgress=0;
            }
        }
    } else { breaking=null; breakProgress=0; }
    
    if(mouse.downR && inReach && !mouse.downL){
        const bid=getBlock(bx,by);
        const sel = hotbar[selected];
        if(bid===0 || bid===BLOCKS.WATER || bid===BLOCKS.LAVA || bid===BLOCKS.TALLGRASS || bid===BLOCKS.FLOWER){
            const px=player.x, py=player.y;
            const blockCollide = bx*BLOCK < px+player.w && bx*BLOCK+BLOCK > px && by*BLOCK < py+player.h && by*BLOCK+BLOCK > py;
            if(!blockCollide || sel===BLOCKS.WATER || sel===BLOCKS.LAVA){
                let placeId=sel;
                if(sel===8) placeId=BLOCKS.WATER;
                else if(sel===9) placeId=BLOCKS.LAVA;
                setBlock(bx,by,placeId);
                playSound(320,'sine',0.05,0.07);
                mouse.downR=false;
            }
        }
    }
    
    // physics sand & water
    const startX=Math.floor(camX/BLOCK)-2, endX=startX+VIEW_W+2;
    const startY=Math.max(0,Math.floor(camY/BLOCK)-2), endY=Math.min(WORLD_H-1, startY+VIEW_H+4);
    if(frame%2===0){
        for(let x=startX;x<endX;x++){
            for(let y=endY;y>=startY;y--){
                const id=getBlock(x,y);
                if(isFalling(id)){
                    const below=getBlock(x,y+1);
                    if(below===0 || below===8 || below===9 || below===14 || below===15){
                        setBlock(x,y,0); setBlock(x,y+1,id);
                    } else if(below!==id){
                        const left=getBlock(x-1,y+1), right=getBlock(x+1,y+1);
                        if(left===0 && getBlock(x-1,y)===0 && Math.random()<0.5){ setBlock(x,y,0); setBlock(x-1,y+1,id); }
                        else if(right===0 && getBlock(x+1,y)===0){ setBlock(x,y,0); setBlock(x+1,y+1,id); }
                    }
                } else if(id===BLOCKS.WATER || id===BLOCKS.LAVA){
                    const isLava=id===BLOCKS.LAVA;
                    if(Math.random() < (isLava?0.15:0.6)){
                        const below=getBlock(x,y+1);
                        if(below===0 || below===BLOCKS.TALLGRASS || below===BLOCKS.FLOWER){ setBlock(x,y+1,id); }
                        else {
                            const dirs=[-1,1]; if(Math.random()<0.5) dirs.reverse();
                            for(const d of dirs){
                                const nx=x+d, ny=y;
                                const nb=getBlock(nx,ny), nbelow=getBlock(nx,ny+1);
                                if(nb===0 && (nbelow!==0 || isLava)){ setBlock(nx,ny,id); break; }
                            }
                        }
                    }
                }
            }
        }
    }
    
    // particles
    for(let i=particles.length-1;i>=0;i--){
        const p=particles[i];
        p.vy+=0.22; p.x+=p.vx0*0.3; p.y+=p.vy; p.life--;
        if(p.life<=0) particles.splice(i,1);
    }
    
    // survival
    if(gameMode==='survival'){
        hungerTimer+=dt;
        if(hungerTimer>560){ hungerTimer=0; hunger=Math.max(0,hunger-1); if(hunger===0) health=Math.max(0,health-1); updateBars(); }
        if(hunger>17 && health<20 && frame%90===0){ health=Math.min(20,health+0.5); updateBars(); }
        if(health<=0) respawn();
    }
    
    // clouds
    clouds.forEach(c=>{ c.x+=c.spd*dt; if(c.x>camX/5+3000) c.x = camX/5-3000; });
}
let frame=0;

function respawn(){
    spawnPlayer();
    health=20; hunger=20;
    updateBars();
    showToast('You respawned!');
    playSound(150,'sawtooth',0.3,0.15);
}

function updateBars(){
    document.getElementById('healthBar').querySelector('.fill').style.width=(health/20*100)+'%';
    document.getElementById('hungerBar').querySelector('.fill').style.width=(hunger/20*100)+'%';
}

function drawBlock(id, x,y){
    ctx.save();
    ctx.translate(x,y);
    const s=TS;
    switch(id){
        case BLOCKS.GRASS:
            ctx.fillStyle='#8B5A2B'; ctx.fillRect(0,0,s,s);
            ctx.fillStyle='#5e9c4f'; ctx.fillRect(0,0,s,s*0.28);
            ctx.fillStyle='#4a7d3f'; ctx.fillRect(0,s*0.28,s,2);
            break;
        case BLOCKS.DIRT: ctx.fillStyle='#8B5A2B'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#6b4423'; for(let i=0;i<3;i++) ctx.fillRect((i*5)%s,((i*7)%s),2,2); break;
        case BLOCKS.STONE: ctx.fillStyle='#9b9b9b'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#868686'; ctx.fillRect(0,0,s,2); ctx.fillRect(0,s-2,s,2); break;
        case BLOCKS.PLANK: ctx.fillStyle='#c49a6c'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#a67c52'; for(let i=1;i<4;i++) ctx.fillRect(0,i*s/4,s,1); break;
        case BLOCKS.SAND: ctx.fillStyle='#e2c98a'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#d4b875'; ctx.fillRect(2,2,s-4,s-4); break;
        case BLOCKS.GLASS: ctx.fillStyle='rgba(160,216,239,0.35)'; ctx.fillRect(0,0,s,s); ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.lineWidth=2; ctx.strokeRect(1,1,s-2,s-2); break;
        case BLOCKS.LEAVES: ctx.fillStyle='#3a8c3e'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#2d6f2f'; for(let i=0;i<4;i++) ctx.fillRect(Math.random()*s,Math.random()*s,3,3); break;
        case BLOCKS.WATER: ctx.fillStyle='rgba(46,107,204,0.7)'; ctx.fillRect(0,0,s,s); ctx.fillStyle='rgba(80,140,230,0.5)'; ctx.fillRect(0,0,s,3); break;
        case BLOCKS.LAVA: ctx.fillStyle='#d04525'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#ff6b35'; ctx.fillRect(2,2,s-4,s-4); break;
        case BLOCKS.LOG: ctx.fillStyle='#6d4c41'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#5d4037'; ctx.fillRect(3,0,s-6,s); ctx.fillStyle='#4e342e'; ctx.fillRect(0,0,s,2); ctx.fillRect(0,s-2,s,2); break;
        case BLOCKS.COAL: ctx.fillStyle='#7a7a7a'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#222'; ctx.fillRect(4,4,5,5); ctx.fillRect(10,9,4,4); break;
        case BLOCKS.IRON: ctx.fillStyle='#9b9b9b'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#d4a574'; ctx.fillRect(3,5,6,3); ctx.fillRect(8,10,5,4); break;
        case BLOCKS.GOLD: ctx.fillStyle='#9b9b9b'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#f1c40f'; ctx.fillRect(4,4,5,5); ctx.fillRect(9,8,4,4); break;
        case BLOCKS.FLOWER: ctx.fillStyle='#8B5A2B'; ctx.fillRect(0,s*0.6,s,s*0.4); ctx.fillStyle='#4caf50'; ctx.fillRect(s*0.45,s*0.3,3,s*0.5); ctx.fillStyle='#e91e63'; ctx.fillRect(s*0.3,2,8,8); break;
        case BLOCKS.TALLGRASS: ctx.strokeStyle='#5e9c4f'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(s*0.3,s); ctx.lineTo(s*0.4,s*0.3); ctx.moveTo(s*0.6,s); ctx.lineTo(s*0.5,s*0.2); ctx.stroke(); break;
        case BLOCKS.BEDROCK: ctx.fillStyle='#2a2a2a'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#1a1a1a'; ctx.fillRect(2,2,s-4,s-4); break;
        case BLOCKS.GRAVEL: ctx.fillStyle='#a0a0a0'; ctx.fillRect(0,0,s,s); ctx.fillStyle='#8a8a8a'; for(let i=0;i<5;i++) ctx.fillRect((i*3)%s,(i*5)%s,2,2); break;
    }
    ctx.restore();
}

function draw(){
    // sky
    const tNorm = (time%24000)/24000;
    const day = Math.sin(tNorm*Math.PI*2 - Math.PI/2)*0.5+0.5;
    const skyTop = `rgb(${ Math.floor(11+90*day) },${ Math.floor(16+120*day) },${ Math.floor(38+100*day) })`;
    const skyBot = `rgb(${ Math.floor(30+120*day) },${ Math.floor(40+150*day) },${ Math.floor(70+140*day) })`;
    const grad=ctx.createLinearGradient(0,0,0,height);
    grad.addColorStop(0,skyTop); grad.addColorStop(1,skyBot);
    ctx.fillStyle=grad; ctx.fillRect(0,0,width,height);
    
    // stars
    if(day<0.3){
        ctx.save(); ctx.globalAlpha=(0.3-day)*3;
        stars.forEach(s=>{ const tw=0.7+0.3*Math.sin(frame*0.02+s.t); ctx.globalAlpha*=tw*s.b; ctx.fillStyle='#fff'; ctx.fillRect(s.x*width, s.y*height*0.7, 2,2); });
        ctx.restore();
    }
    // sun/moon
    const sunAng = tNorm*Math.PI*2 - Math.PI/2;
    const sx = width*0.5 + Math.cos(sunAng)*width*0.4;
    const sy = height*0.5 + Math.sin(sunAng)*height*0.35;
    ctx.save(); ctx.globalAlpha=0.9;
    if(day>0.2){ ctx.fillStyle='#ffdb70'; ctx.beginPath(); ctx.arc(sx,sy,28,0,Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(255,219,112,0.2)'; ctx.beginPath(); ctx.arc(sx,sy,45,0,Math.PI*2); ctx.fill(); }
    else { ctx.fillStyle='#e0e7ff'; ctx.beginPath(); ctx.arc(sx,sy,22,0,Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(180,190,220,0.15)'; ctx.beginPath(); ctx.arc(sx-8,sy-6,6,0,Math.PI*2); ctx.fill(); }
    ctx.restore();
    
    // clouds parallax
    ctx.save();
    ctx.translate(-(camX*0.15)% (TS*40),0);
    clouds.forEach(c=>{
        const cx = (c.x - camX*0.1) % (width+400) - 200;
        const cy = c.y;
        ctx.globalAlpha=0.85;
        ctx.fillStyle='#fff';
        const sz = c.s*TS*0.6;
        ctx.beginPath(); ctx.arc(cx,cy,sz*0.5,0,7); ctx.arc(cx+sz*0.4,cy,sz*0.4,0,7); ctx.arc(cx-sz*0.35,cy+5,sz*0.35,0,7); ctx.fill();
    });
    ctx.restore();
    
    // world
    const startX=Math.floor(camX/BLOCK)-1, endX=startX+VIEW_W+2;
    const startY=Math.max(0,Math.floor(camY/BLOCK)-1), endY=Math.min(WORLD_H-1, startY+VIEW_H+2);
    ctx.save(); ctx.scale(SCALE,SCALE); ctx.translate(-camX,-camY);
    for(let y=startY;y<=endY;y++){
        for(let x=startX;x<=endX;x++){
            const id=getBlock(x,y);
            if(id!==0){
                drawBlock(id, x*BLOCK, y*BLOCK);
            }
        }
    }
    // particles
    particles.forEach(p=>{ ctx.fillStyle=p.color; ctx.globalAlpha=Math.min(1,p.life/30); ctx.fillRect((p.x-camX)/SCALE, (p.y-camY)/SCALE, p.sz,p.sz); });
    ctx.globalAlpha=1;
    
    // player
    ctx.save();
    const px=player.x, py=player.y;
    // shadow
    ctx.globalAlpha=0.2; ctx.fillStyle='#000'; ctx.beginPath(); ctx.ellipse(px+player.w/2, py+player.h+2, player.w*0.4, 4,0,0,7); ctx.fill(); ctx.globalAlpha=1;
    // body
    ctx.translate(px,py);
    const crouch = inputs.down ? 0.7 : 1;
    // legs
    ctx.fillStyle='#3b5fcc'; ctx.fillRect(2,14*crouch,4,10*crouch); ctx.fillRect(6,14*crouch,4,10*crouch);
    // torso
    ctx.fillStyle='#4a7bd1'; ctx.fillRect(1,6*crouch,10,9*crouch);
    // arms
    ctx.fillStyle='#e8b888'; ctx.fillRect(0,7*crouch,2,7*crouch); ctx.fillRect(10,7*crouch,2,7*crouch);
    // head
    ctx.fillStyle='#e8b888'; ctx.fillRect(2,0,8,8);
    ctx.fillStyle='#2a1f14'; ctx.fillRect(2,0,8,3);
    // eyes
    ctx.fillStyle='#000'; ctx.fillRect(player.facing>0?6:4,3,1,1); ctx.fillRect(player.facing>0?7:3,3,1,1);
    ctx.restore();
    
    // breaking overlay
    if(breaking){
        const bx=breaking.x*BLOCK, by=breaking.y*BLOCK;
        ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=2; ctx.globalAlpha=0.7;
        const prog=Math.min(1,breakProgress);
        ctx.strokeRect(bx+1,by+1,BLOCK-2,BLOCK-2);
        for(let i=0;i<prog*8;i++){ ctx.beginPath(); ctx.moveTo(bx+Math.random()*BLOCK, by+Math.random()*BLOCK); ctx.lineTo(bx+Math.random()*BLOCK, by+Math.random()*BLOCK); ctx.stroke(); }
    }
    // hover
    const hx=Math.floor(mouse.worldX/BLOCK), hy=Math.floor(mouse.worldY/BLOCK);
    const hd=Math.hypot((hx*BLOCK+BLOCK/2)-(player.x+player.w/2), (hy*BLOCK+BLOCK/2)-(player.y+player.h/2));
    if(hd<90 && getBlock(hx,hy)!==0){
        ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=2; ctx.setLineDash([4,3]); ctx.strokeRect(hx*BLOCK+0.5, hy*BLOCK+0.5, BLOCK-1, BLOCK-1); ctx.setLineDash([]);
    }
    ctx.restore();
    
    // lighting
    const underground = player.y/BLOCK > 75;
    let dark = (1-day)*0.65 + (underground?0.3:0);
    if(dark>0){ ctx.fillStyle=`rgba(5,8,25,${Math.min(0.82,dark)})`; ctx.fillRect(0,0,width,height); }
    
    // crosshair
    ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(width/2-10, height/2); ctx.lineTo(width/2+10, height/2); ctx.moveTo(width/2, height/2-10); ctx.lineTo(width/2, height/2+10); ctx.stroke();
}

function loop(ts){
    frame++;
    update(1);
    draw();
    requestAnimationFrame(loop);
}

// events
window.addEventListener('resize',()=>{ width=innerWidth; height=innerHeight; canvas.width=width*dpr; canvas.height=height*dpr; ctx.setTransform(1,0,0,1); ctx.scale(dpr,dpr); });

canvas.addEventListener('mousemove',e=>{ const r=canvas.getBoundingClientRect(); mouse.x=e.clientX-r.left; mouse.y=e.clientY-r.top; });
canvas.addEventListener('mousedown',e=>{ initAudio(); if(e.button===0) mouse.downL=true; if(e.button===2) mouse.downR=true; });
window.addEventListener('mouseup',e=>{ if(e.button===0) mouse.downL=false; if(e.button===2) mouse.downR=false; });
canvas.addEventListener('contextmenu',e=>e.preventDefault());
canvas.addEventListener('wheel',e=>{ e.preventDefault(); selected=(selected+(e.deltaY>0?1:-1)+9)%9; updateHotbar(); },{passive:false});

window.addEventListener('keydown',e=>{
    if(['1','2','3','4','5','6','7','8','9'].includes(e.key)){ selected=parseInt(e.key)-1; updateHotbar(); }
    if(e.key==='a'||e.key==='ArrowLeft') inputs.left=true;
    if(e.key==='d'||e.key==='ArrowRight') inputs.right=true;
    if(e.key==='w'||e.key===' '||e.key==='ArrowUp') inputs.up=true;
    if(e.key==='s'||e.key==='ArrowDown') inputs.down=true;
});
window.addEventListener('keyup',e=>{
    if(e.key==='a'||e.key==='ArrowLeft') inputs.left=false;
    if(e.key==='d'||e.key==='ArrowRight') inputs.right=false;
    if(e.key==='w'||e.key===' '||e.key==='ArrowUp') inputs.up=false;
    if(e.key==='s'||e.key==='ArrowDown') inputs.down=false;
});

// mobile
const isTouch='ontouchstart' in window;
if(isTouch) document.getElementById('mobileControls').style.display='block';
const joy=document.getElementById('joystick'), stick=document.getElementById('stick');
let joyActive=false, joyId=null;
joy.addEventListener('touchstart',e=>{ joyActive=true; joyId=e.touches[0].identifier; e.preventDefault(); });
window.addEventListener('touchmove',e=>{
    for(const t of e.touches){
        if(t.identifier===joyId){
            const r=joy.getBoundingClientRect(), cx=r.left+r.width/2, cy=r.top+r.height/2;
            let dx=t.clientX-cx, dy=t.clientY-cy; const d=Math.hypot(dx,dy), m=40; if(d>m){dx=dx/d*m; dy=dy/d*m;}
            stick.style.transform=`translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
            inputs.left=dx<-15; inputs.right=dx>15;
        }
    }
},{passive:false});
window.addEventListener('touchend',e=>{
    for(const t of e.changedTouches){ if(t.identifier===joyId){ joyActive=false; joyId=null; stick.style.transform='translate(-50%,-50%)'; inputs.left=false; inputs.right=false; } }
});
document.getElementById('jumpBtn').addEventListener('touchstart',e=>{e.preventDefault(); inputs.up=true; setTimeout(()=>inputs.up=false,150); initAudio();});
let mobileMode='break';
const actionBtn=document.getElementById('actionBtn'), modeBtnM=document.getElementById('modeBtnM');
function updateMobileBtns(){ actionBtn.textContent=mobileMode==='break'?'BREAK':'PLACE'; }
updateMobileBtns();
modeBtnM.addEventListener('touchstart',e=>{e.preventDefault(); mobileMode=mobileMode==='break'?'place':'break'; updateMobileBtns();});
actionBtn.addEventListener('touchstart',e=>{
    e.preventDefault(); initAudio();
    const bx=Math.floor((player.x+player.w/2 + player.facing*BLOCK*2.5)/BLOCK);
    const by=Math.floor((player.y+player.h/2)/BLOCK);
    if(mobileMode==='break'){ if(getBlock(bx,by)!==0){ setBlock(bx,by,0); playSound(240,'square',0.07,0.1); addParticle(bx*BLOCK,by*BLOCK,'#aaa'); } }
    else { const id=hotbar[selected]; if(getBlock(bx,by)===0){ setBlock(bx,by, id===8?8: id===9?9: id); playSound(320,'sine',0.05,0.07); } }
});
canvas.addEventListener('touchstart',e=>{
    if(e.target.closest('#mobileControls')) return;
    e.preventDefault(); initAudio();
    const t=e.touches[0], r=canvas.getBoundingClientRect();
    mouse.x=t.clientX-r.left; mouse.y=t.clientY-r.top;
    mouse.worldX=camX+mouse.x/SCALE; mouse.worldY=camY+mouse.y/SCALE;
    const bx=Math.floor(mouse.worldX/BLOCK), by=Math.floor(mouse.worldY/BLOCK);
    if(mobileMode==='break') mouse.downL=true; else mouse.downR=true;
    setTimeout(()=>{mouse.downL=false; mouse.downR=false;},120);
},{passive:false});

// UI buttons
document.getElementById('saveBtn').onclick=()=>{
    const data={mod:Array.from(worldMod.entries()),player:{x:player.x,y:player.y},time,mode:gameMode,health,hunger};
    localStorage.setItem('pixelSandbox',JSON.stringify(data));
    showToast('World saved!');
    playSound(600,'sine',0.12,0.1);
};
document.getElementById('loadBtn').onclick=()=>{
    const raw=localStorage.getItem('pixelSandbox');
    if(!raw){ showToast('No save found'); return; }
    try{
        const data=JSON.parse(raw);
        worldMod.clear(); data.mod.forEach(([k,v])=>worldMod.set(k,v));
        player.x=data.player.x; player.y=data.player.y; time=data.time||8000; gameMode=data.mode||'creative';
        health=data.health||20; hunger=data.hunger||20;
        document.getElementById('modeBtn').textContent=gameMode==='creative'?'Creative Mode':'Survival Mode';
        document.getElementById('modeLabel').textContent=gameMode==='creative'?'Creative':'Survival';
        document.getElementById('healthBar').classList.toggle('active',gameMode==='survival');
        document.getElementById('hungerBar').classList.toggle('active',gameMode==='survival');
        updateBars(); showToast('World loaded!'); playSound(700,'sine',0.15,0.1);
    }catch{ showToast('Failed to load'); }
};
document.getElementById('resetBtn').onclick=()=>{
    if(confirm('Reset world? All buildings will be lost.')){ worldMod.clear(); worldGen.clear(); spawnPlayer(); time=8000; showToast('World reset'); }
};
document.getElementById('modeBtn').onclick=()=>{
    gameMode = gameMode==='creative' ? 'survival' : 'creative';
    const isSur = gameMode==='survival';
    document.getElementById('modeBtn').textContent=isSur?'Survival Mode':'Creative Mode';
    document.getElementById('modeLabel').textContent=isSur?'Survival':'Creative';
    document.getElementById('healthBar').classList.toggle('active',isSur);
    document.getElementById('hungerBar').classList.toggle('active',isSur);
    if(isSur){ health=20; hunger=20; updateBars(); }
    showToast(isSur?'Survival Mode enabled':'Creative Mode enabled');
    playSound(500,'triangle',0.1,0.08);
};
document.getElementById('startBtn').onclick=()=>{ document.getElementById('tutorial').style.display='none'; initAudio(); playSound(650,'sine',0.2,0.12); };

updateBars();
requestAnimationFrame(loop);

// prevent scroll
document.body.addEventListener('touchmove',e=>{ if(e.target===canvas) e.preventDefault(); },{passive:false});
})();
</script>
</body>
</html>

Game Source: Pixel Sandbox - 2D Sandbox Game

Creator: SonicLegend13

Libraries: none

Complexity: complex (907 lines, 36.9 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: pixel-sandbox-2d-sandbox-game-soniclegend13" to link back to the original. Then publish at arcadelab.ai/publish.