🎮ArcadeLab

SandWorld FPS

by TurboMeteor43
875 lines30.1 KB
▶ Play
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>SandWorld FPS</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-touch-callout: none; }
        body {
            background: #0a0a1a;
            overflow: hidden;
            touch-action: none;
            font-family: Arial, sans-serif;
            position: fixed;
            width: 100%;
            height: 100%;
        }
        canvas {
            display: block;
            width: 100vw;
            height: 100vh;
            image-rendering: pixelated;
            cursor: crosshair;
            touch-action: none;
        }
        #crosshair {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 5;
            pointer-events: none;
            color: rgba(255,255,255,0.8);
            font-size: 28px;
            font-family: monospace;
            text-shadow: 0 0 6px rgba(0,0,0,0.9);
        }
        #info {
            position: fixed;
            top: 10px;
            left: 10px;
            color: #fff;
            font-size: 12px;
            background: rgba(0,0,0,0.7);
            padding: 6px 12px;
            border-radius: 8px;
            z-index: 10;
            pointer-events: none;
            font-family: monospace;
            border: 1px solid rgba(255,255,255,0.1);
        }
        #guide {
            position: fixed;
            bottom: 120px;
            left: 50%;
            transform: translateX(-50%);
            color: #ff0;
            font-size: 14px;
            background: rgba(0,0,0,0.8);
            padding: 8px 18px;
            border-radius: 12px;
            z-index: 10;
            text-align: center;
            max-width: 92%;
            pointer-events: none;
            transition: opacity 0.4s;
            border: 1px solid rgba(255,255,255,0.1);
            font-weight: bold;
            text-shadow: 0 0 10px rgba(0,0,0,0.5);
        }
        #hotbar {
            position: fixed;
            bottom: 10px;
            left: 50%;
            transform: translateX(-50%);
            display: flex;
            gap: 4px;
            background: rgba(0,0,0,0.75);
            padding: 6px 10px;
            border-radius: 12px;
            z-index: 10;
            border: 1px solid rgba(255,255,255,0.15);
            backdrop-filter: blur(4px);
            pointer-events: none;
        }
        #hotbar > * { pointer-events: auto; }
        .slot {
            width: 48px;
            height: 48px;
            background: rgba(255,255,255,0.08);
            border: 2px solid rgba(255,255,255,0.15);
            border-radius: 6px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            font-size: 8px;
            color: #fff;
            cursor: pointer;
            position: relative;
            transition: all 0.1s;
            touch-action: manipulation;
        }
        .slot.active {
            border-color: #ffd740;
            background: rgba(255,215,64,0.2);
            box-shadow: 0 0 15px rgba(255,215,64,0.2);
        }
        .slot .count {
            font-size: 10px;
            color: #ffd740;
            font-weight: bold;
        }
        .slot .name {
            font-size: 7px;
            color: #aaa;
            text-align: center;
            line-height: 1.1;
        }
        .slot:active { transform: scale(0.9); }

        /* Мобильные контроллеры */
        #mobileControls {
            position: fixed;
            bottom: 0;
            left: 0;
            right: 0;
            top: 0;
            z-index: 8;
            pointer-events: none;
            display: none;
        }
        #mobileControls.active {
            display: block;
        }
        #mobileControls > * {
            pointer-events: auto;
        }

        #leftJoystick {
            position: absolute;
            bottom: 90px;
            left: 30px;
            width: 120px;
            height: 120px;
            border-radius: 60px;
            background: rgba(255,255,255,0.12);
            border: 2px solid rgba(255,255,255,0.2);
            backdrop-filter: blur(4px);
            touch-action: none;
        }
        #leftJoystick .knob {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 50px;
            height: 50px;
            border-radius: 25px;
            background: rgba(255,255,255,0.35);
            transform: translate(-50%, -50%);
            border: 2px solid rgba(255,255,255,0.3);
            transition: none;
        }

        #rightJoystick {
            position: absolute;
            bottom: 90px;
            right: 30px;
            width: 120px;
            height: 120px;
            border-radius: 60px;
            background: rgba(255,255,255,0.12);
            border: 2px solid rgba(255,255,255,0.2);
            backdrop-filter: blur(4px);
            touch-action: none;
        }
        #rightJoystick .knob {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 50px;
            height: 50px;
            border-radius: 25px;
            background: rgba(255,255,255,0.35);
            transform: translate(-50%, -50%);
            border: 2px solid rgba(255,255,255,0.3);
            transition: none;
        }

        .action-btn {
            position: absolute;
            width: 64px;
            height: 64px;
            border-radius: 50%;
            border: 2px solid rgba(255,255,255,0.3);
            background: rgba(255,255,255,0.15);
            backdrop-filter: blur(4px);
            color: #fff;
            font-size: 22px;
            display: flex;
            align-items: center;
            justify-content: center;
            touch-action: manipulation;
            transition: all 0.1s;
            font-weight: bold;
            text-shadow: 0 0 10px rgba(0,0,0,0.5);
        }
        .action-btn:active {
            transform: scale(0.85);
            background: rgba(255,255,255,0.3);
        }
        #btnJump {
            position: absolute;
            bottom: 220px;
            right: 20px;
            background: rgba(76,175,80,0.3);
            border-color: rgba(76,175,80,0.5);
            font-size: 18px;
        }
        #btnBreak {
            position: absolute;
            bottom: 130px;
            left: 180px;
            background: rgba(244,67,54,0.3);
            border-color: rgba(244,67,54,0.5);
            width: 72px;
            height: 72px;
            font-size: 24px;
        }
        #btnPlace {
            position: absolute;
            bottom: 130px;
            left: 270px;
            background: rgba(33,150,243,0.3);
            border-color: rgba(33,150,243,0.5);
            width: 72px;
            height: 72px;
            font-size: 24px;
        }

        @media (max-width: 700px) {
            #leftJoystick { width: 100px; height: 100px; bottom: 80px; left: 20px; }
            #leftJoystick .knob { width: 40px; height: 40px; }
            #rightJoystick { width: 100px; height: 100px; bottom: 80px; right: 20px; }
            #rightJoystick .knob { width: 40px; height: 40px; }
            .action-btn { width: 54px; height: 54px; font-size: 18px; }
            #btnJump { width: 60px; height: 60px; font-size: 16px; bottom: 200px; right: 15px; }
            #btnBreak { width: 60px; height: 60px; font-size: 20px; bottom: 110px; left: 140px; }
            #btnPlace { width: 60px; height: 60px; font-size: 20px; bottom: 110px; left: 215px; }
            .slot { width: 40px; height: 40px; font-size: 7px; }
            #info { font-size: 10px; padding: 4px 8px; }
            #guide { font-size: 11px; bottom: 100px; padding: 6px 12px; }
        }
        @media (max-width: 450px) {
            #leftJoystick { width: 80px; height: 80px; bottom: 70px; left: 12px; }
            #leftJoystick .knob { width: 32px; height: 32px; }
            #rightJoystick { width: 80px; height: 80px; bottom: 70px; right: 12px; }
            #rightJoystick .knob { width: 32px; height: 32px; }
            .action-btn { width: 46px; height: 46px; font-size: 14px; }
            #btnJump { width: 50px; height: 50px; font-size: 14px; bottom: 170px; right: 10px; }
            #btnBreak { width: 50px; height: 50px; font-size: 16px; bottom: 90px; left: 110px; }
            #btnPlace { width: 50px; height: 50px; font-size: 16px; bottom: 90px; left: 175px; }
            .slot { width: 34px; height: 34px; font-size: 6px; }
            #hotbar { padding: 4px 6px; gap: 3px; bottom: 6px; }
        }
    </style>
</head>
<body>

<div id="info">🏔️ <span id="blockCount">0</span> | 🎯 <span id="targetInfo">-</span></div>
<div id="crosshair">✚</div>
<div id="guide">💡 Кликни по экрану → управление мышью | WASD — движение</div>

<canvas id="game"></canvas>

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

<!-- Мобильные контроллеры -->
<div id="mobileControls">
    <div id="leftJoystick"><div class="knob" id="leftKnob"></div></div>
    <div id="rightJoystick"><div class="knob" id="rightKnob"></div></div>
    <div class="action-btn" id="btnJump">⬆</div>
    <div class="action-btn" id="btnBreak">⛏</div>
    <div class="action-btn" id="btnPlace">📦</div>
</div>

<script>
// ============================================================
// SANDWORLD FPS — УДОБНОЕ УПРАВЛЕНИЕ
// ============================================================

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const blockCountEl = document.getElementById('blockCount');
const targetInfoEl = document.getElementById('targetInfo');
const guideEl = document.getElementById('guide');
const hotbarEl = document.getElementById('hotbar');
const mobileControls = document.getElementById('mobileControls');

// ============================================================
// РАЗМЕРЫ
// ============================================================
const TILE = 32;
const WORLD_W = 100;
const WORLD_H = 70;

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// Определяем мобильное устройство
const isMobile = /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || window.innerWidth < 800;

if (isMobile) {
    mobileControls.classList.add('active');
    guideEl.textContent = '🎮 Левый джойстик — движение | Правый — взгляд | ⛏ — добыть | 📦 — поставить';
} else {
    guideEl.textContent = '💡 Кликни по экрану → блокировка мыши | WASD — движение | Пробел — прыжок';
}

// ============================================================
// ТИПЫ БЛОКОВ
// ============================================================
const BLOCKS = {
    AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, WOOD: 4, LEAF: 5,
    SAND: 6, PLANKS: 7, COBBLESTONE: 8, GLASS: 9, BRICK: 10,
    IRON_ORE: 11, GOLD_ORE: 12, CHEST: 13, CRAFTING_TABLE: 14,
    TORCH: 15, WATER: 16, SNOW: 17, ICE: 18
};
const BLOCK_NAMES = {
    0: 'Воздух', 1: 'Трава', 2: 'Земля', 3: 'Камень',
    4: 'Древесина', 5: 'Листва', 6: 'Песок', 7: 'Доски',
    8: 'Булыжник', 9: 'Стекло', 10: 'Кирпич',
    11: 'Железная руда', 12: 'Золотая руда', 13: 'Сундук',
    14: 'Верстак', 15: 'Факел', 16: 'Вода', 17: 'Снег', 18: 'Лёд'
};
const BLOCK_COLORS = {
    0: null, 1: '#5D8A3C', 2: '#8D6E63', 3: '#78909C',
    4: '#6D4C41', 5: '#2E7D32', 6: '#FDD835', 7: '#D7A86E',
    8: '#9E9E9E', 9: '#B3E5FC', 10: '#A1887F',
    11: '#FF8A65', 12: '#FFD54F', 13: '#FFB300',
    14: '#795548', 15: '#FFD740', 16: '#42A5F5',
    17: '#ECEFF1', 18: '#81D4FA'
};

// ============================================================
// МИР
// ============================================================
let world = [];
let seed = Date.now() % 10000;

function generateWorld() {
    world = [];
    for (let x = 0; x < WORLD_W; x++) {
        world[x] = [];
        let height = 25 + Math.floor(6 * Math.sin(x / 12 + seed * 0.1) + 4 * Math.sin(x / 5 + seed * 0.3));
        for (let y = 0; y < WORLD_H; y++) {
            if (y > height + 12) {
                world[x][y] = Math.random() < 0.25 ? BLOCKS.AIR : BLOCKS.STONE;
            } else if (y > height) {
                world[x][y] = (y < height + 4) ? BLOCKS.DIRT : BLOCKS.STONE;
            } else if (y === height) {
                world[x][y] = (height > 28) ? BLOCKS.SNOW : BLOCKS.GRASS;
            } else if (y > height - 4) {
                world[x][y] = BLOCKS.DIRT;
            } else {
                world[x][y] = BLOCKS.AIR;
            }
        }
    }
    for (let x = 4; x < WORLD_W - 4; x += 2 + Math.floor(Math.random() * 4)) {
        let y = getSurfaceHeight(x);
        if (y > 3 && y < WORLD_H - 10 && Math.random() < 0.5 && getBlock(x, y) === BLOCKS.GRASS) {
            let h = 4 + Math.floor(Math.random() * 5);
            for (let i = 0; i < h; i++) if (y - i >= 0) world[x][y - i] = BLOCKS.WOOD;
            for (let dx = -2; dx <= 2; dx++)
                for (let dy = -3; dy <= 1; dy++)
                    if (Math.abs(dx) + Math.abs(dy) <= 3 && x + dx >= 0 && x + dx < WORLD_W && y - h + dy >= 0)
                        world[x + dx][y - h + dy] = BLOCKS.LEAF;
        }
    }
    for (let i = 0; i < 40; i++) {
        let x = 3 + Math.floor(Math.random() * (WORLD_W - 6));
        let y = 3 + Math.floor(Math.random() * (getSurfaceHeight(x) + 12));
        if (world[x] && world[x][y] === BLOCKS.STONE) {
            world[x][y] = Math.random() < 0.7 ? BLOCKS.IRON_ORE : BLOCKS.GOLD_ORE;
        }
    }
    for (let i = 0; i < 6; i++) {
        let cx = 10 + Math.floor(Math.random() * (WORLD_W - 20));
        let cy = getSurfaceHeight(cx) - 1;
        for (let dx = -2; dx <= 2; dx++)
            for (let dy = -1; dy <= 1; dy++)
                if (Math.abs(dx) + Math.abs(dy) <= 3 && cx + dx >= 0 && cx + dx < WORLD_W && cy + dy >= 0)
                    world[cx + dx][cy + dy] = BLOCKS.WATER;
    }
}

function getSurfaceHeight(x) {
    for (let y = 0; y < WORLD_H; y++) {
        let b = getBlock(x, y);
        if (b === BLOCKS.GRASS || b === BLOCKS.SNOW || b === BLOCKS.DIRT || b === BLOCKS.STONE) return y;
    }
    return WORLD_H - 5;
}

function getBlock(x, y) {
    if (x < 0 || x >= WORLD_W || y < 0 || y >= WORLD_H) return BLOCKS.AIR;
    return world[x][y] || BLOCKS.AIR;
}

function setBlock(x, y, type) {
    if (x < 0 || x >= WORLD_W || y < 0 || y >= WORLD_H) return;
    world[x][y] = type;
}

function isSolid(x, y) {
    let b = getBlock(x, y);
    return b !== BLOCKS.AIR && b !== BLOCKS.WATER;
}

// ============================================================
// ИГРОК
// ============================================================
const player = {
    x: WORLD_W / 2,
    y: 10,
    vx: 0,
    vy: 0,
    speed: 4.0,
    jump: 7.0,
    gravity: 0.4,
    onGround: false,
    selectedSlot: 0,
    inventory: [],
    pitch: 0,
    yaw: 0,
    handSwing: 0,
    isMining: false
};

for (let i = 0; i < 9; i++) {
    let types = [BLOCKS.WOOD, BLOCKS.DIRT, BLOCKS.STONE, BLOCKS.PLANKS, BLOCKS.COBBLESTONE, BLOCKS.GRASS, BLOCKS.SAND, BLOCKS.BRICK, BLOCKS.GLASS];
    player.inventory.push({ type: types[i % types.length], count: 15 + Math.floor(Math.random() * 10) });
}

// ============================================================
// УПРАВЛЕНИЕ
// ============================================================
const keys = {};
let isPointerLocked = false;
let isMouseDown = false;
let isMouseDownRight = false;
let mouseDX = 0, mouseDY = 0;

// ----- ПК управление -----
canvas.addEventListener('click', () => {
    if (!isPointerLocked && !isMobile) {
        canvas.requestPointerLock();
    }
});
document.addEventListener('pointerlockchange', () => {
    isPointerLocked = document.pointerLockElement === canvas;
    if (isPointerLocked) guideEl.textContent = '🖱️ WASD — движение | Пробел — прыжок | ЛКМ/ПКМ — добыча/установка';
});
document.addEventListener('mousemove', e => {
    if (isPointerLocked) {
        mouseDX += e.movementX;
        mouseDY += e.movementY;
    }
});
document.addEventListener('keydown', e => { keys[e.key.toLowerCase()] = true; });
document.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
canvas.addEventListener('mousedown', e => {
    if (e.button === 0) isMouseDown = true;
    if (e.button === 2) isMouseDownRight = true;
});
canvas.addEventListener('mouseup', e => {
    if (e.button === 0) isMouseDown = false;
    if (e.button === 2) isMouseDownRight = false;
});
canvas.addEventListener('contextmenu', e => e.preventDefault());

// ----- Мобильное управление -----
let leftJoyX = 0, leftJoyY = 0;
let rightJoyX = 0, rightJoyY = 0;
let mobileJump = false, mobileBreak = false, mobilePlace = false;

function setupJoystick(element, knob, callback) {
    let rect = element.getBoundingClientRect();
    let centerX = rect.width / 2;
    let centerY = rect.height / 2;
    let maxR = rect.width / 2 - 10;

    function handleMove(clientX, clientY) {
        let dx = clientX - rect.left - centerX;
        let dy = clientY - rect.top - centerY;
        let dist = Math.sqrt(dx * dx + dy * dy);
        if (dist > maxR) { dx = dx / dist * maxR; dy = dy / dist * maxR; }
        knob.style.transform = `translate(${dx}px, ${dy}px)`;
        callback(dx / maxR, dy / maxR);
    }

    function reset() {
        knob.style.transform = 'translate(0px, 0px)';
        callback(0, 0);
    }

    element.addEventListener('touchstart', e => {
        e.preventDefault();
        let t = e.touches[0];
        handleMove(t.clientX, t.clientY);
    });
    element.addEventListener('touchmove', e => {
        e.preventDefault();
        let t = e.touches[0];
        handleMove(t.clientX, t.clientY);
    });
    element.addEventListener('touchend', e => {
        e.preventDefault();
        reset();
    });
    element.addEventListener('touchcancel', e => {
        reset();
    });
}

setupJoystick(
    document.getElementById('leftJoystick'),
    document.getElementById('leftKnob'),
    (x, y) => { leftJoyX = x; leftJoyY = y; }
);
setupJoystick(
    document.getElementById('rightJoystick'),
    document.getElementById('rightKnob'),
    (x, y) => {
        rightJoyX = x;
        rightJoyY = y;
        // Поворот от правого джойстика
        if (Math.abs(x) > 0.1 || Math.abs(y) > 0.1) {
            mouseDX += x * 3;
            mouseDY += y * 3;
        }
    }
);

document.getElementById('btnJump').addEventListener('touchstart', e => { e.preventDefault(); mobileJump = true; });
document.getElementById('btnJump').addEventListener('touchend', e => { e.preventDefault(); mobileJump = false; });
document.getElementById('btnBreak').addEventListener('touchstart', e => { e.preventDefault(); mobileBreak = true; });
document.getElementById('btnBreak').addEventListener('touchend', e => { e.preventDefault(); mobileBreak = false; });
document.getElementById('btnPlace').addEventListener('touchstart', e => { e.preventDefault(); mobilePlace = true; });
document.getElementById('btnPlace').addEventListener('touchend', e => { e.preventDefault(); mobilePlace = false; });

// ============================================================
// ИГРОВАЯ ЛОГИКА
// ============================================================
function getTargetBlock() {
    let cx = player.x, cy = player.y;
    let pitch = player.pitch, yaw = player.yaw;
    let dirX = Math.sin(yaw) * Math.cos(pitch);
    let dirY = -Math.sin(pitch);
    let dirZ = Math.cos(yaw) * Math.cos(pitch);
    for (let d = 0; d < 8; d += 0.05) {
        let tx = cx + dirX * d;
        let ty = cy + dirY * d;
        let bx = Math.floor(tx);
        let by = Math.floor(ty);
        if (isSolid(bx, by)) return { x: bx, y: by, dist: d };
    }
    return null;
}

function getPlaceBlock() {
    let target = getTargetBlock();
    if (!target) return null;
    let bx = target.x, by = target.y;
    let cx = player.x, cy = player.y;
    let dx = bx + 0.5 - cx;
    let dy = by + 0.5 - cy;
    if (Math.abs(dx) > Math.abs(dy)) bx += dx > 0 ? -1 : 1;
    else by += dy > 0 ? -1 : 1;
    if (isSolid(bx, by)) return null;
    return { x: bx, y: by };
}

function updatePlayer(dt) {
    let sensitivity = 0.004;
    player.yaw += mouseDX * sensitivity;
    player.pitch += mouseDY * sensitivity;
    player.pitch = Math.max(-1.2, Math.min(1.2, player.pitch));
    mouseDX = 0;
    mouseDY = 0;

    let moveX = 0, moveY = 0;
    // ПК управление
    if (keys['w']) moveY -= 1;
    if (keys['s']) moveY += 1;
    if (keys['a']) moveX -= 1;
    if (keys['d']) moveX += 1;
    // Мобильное управление
    if (isMobile) {
        if (Math.abs(leftJoyX) > 0.1) moveX = leftJoyX;
        if (Math.abs(leftJoyY) > 0.1) moveY = -leftJoyY;
    }

    let len = Math.sqrt(moveX * moveX + moveY * moveY);
    if (len > 1) { moveX /= len; moveY /= len; }

    let forward = Math.cos(player.yaw);
    let right = Math.sin(player.yaw);
    let vx = (moveX * right + moveY * forward) * player.speed;
    let vy = (moveX * -forward + moveY * right) * player.speed;

    // Прыжок
    let jumpPressed = keys[' '] || keys['space'] || mobileJump;
    if (jumpPressed && player.onGround) {
        player.vy = -player.jump;
        player.onGround = false;
    }

    player.vy += player.gravity;
    if (player.vy > 12) player.vy = 12;

    // Горизонталь
    let newX = player.x + vx * dt;
    let bx = Math.floor(newX);
    let by = Math.floor(player.y);
    if (!isSolid(bx, by) && !isSolid(bx, by + 1)) {
        player.x = newX;
    }

    // Вертикаль
    let newY = player.y + player.vy * dt;
    let bx2 = Math.floor(player.x);
    let by2 = Math.floor(newY);
    let by3 = Math.floor(newY + 1);
    if (!isSolid(bx2, by2) && !isSolid(bx2, by3)) {
        player.y = newY;
        player.onGround = false;
    } else {
        if (player.vy > 0) player.onGround = true;
        player.vy = 0;
    }

    if (player.x < 0.5) player.x = 0.5;
    if (player.x > WORLD_W - 0.5) player.x = WORLD_W - 0.5;
    if (player.y < 0.5) { player.y = 0.5; player.vy = 0; }
    if (player.y > WORLD_H + 10) {
        player.y = getSurfaceHeight(Math.floor(player.x)) - 1;
        player.vy = 0;
        showGuide('Возврат из бездны!');
    }

    // Анимация руки
    let mining = isMouseDown || mobileBreak;
    let placing = isMouseDownRight || mobilePlace;
    if (mining || placing) {
        player.handSwing += dt * 8;
        if (player.handSwing > 1) player.handSwing = 1;
    } else {
        player.handSwing *= 0.95;
        if (player.handSwing < 0.01) player.handSwing = 0;
    }

    // Взаимодействие
    interactTimer -= dt;
    if (interactTimer <= 0) {
        if (mining) {
            let target = getTargetBlock();
            if (target && target.dist < 6) {
                let b = getBlock(target.x, target.y);
                if (b !== BLOCKS.AIR && b !== BLOCKS.WATER) {
                    setBlock(target.x, target.y, BLOCKS.AIR);
                    addToInventory(b, 1);
                    showGuide(`Добыто: ${BLOCK_NAMES[b]}`);
                    interactTimer = 0.15;
                    player.handSwing = 1;
                }
            }
        }
        if (placing) {
            let place = getPlaceBlock();
            if (place) {
                let item = player.inventory[player.selectedSlot];
                if (item && item.count > 0 && item.type !== BLOCKS.AIR && item.type !== BLOCKS.WATER) {
                    setBlock(place.x, place.y, item.type);
                    item.count--;
                    if (item.count <= 0) player.inventory[player.selectedSlot] = { type: BLOCKS.AIR, count: 0 };
                    showGuide(`Установлен: ${BLOCK_NAMES[item.type]}`);
                    interactTimer = 0.15;
                    player.handSwing = 0.7;
                }
            }
        }
    }
}

let interactTimer = 0;

function addToInventory(type, count) {
    for (let i = 0; i < player.inventory.length; i++) {
        if (player.inventory[i].type === type) {
            player.inventory[i].count += count;
            updateHotbar();
            return;
        }
    }
    for (let i = 0; i < player.inventory.length; i++) {
        if (player.inventory[i].type === BLOCKS.AIR || player.inventory[i].count === 0) {
            player.inventory[i] = { type: type, count: count };
            updateHotbar();
            return;
        }
    }
}

// ============================================================
// ОТРИСОВКА
// ============================================================
function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    let grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
    grad.addColorStop(0, '#0a0a2a');
    grad.addColorStop(0.3, '#1a237e');
    grad.addColorStop(0.6, '#42A5F5');
    grad.addColorStop(0.9, '#87CEEB');
    grad.addColorStop(1, '#B3E5FC');
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    let offsetX = player.x * TILE - canvas.width / 2;
    let offsetY = player.y * TILE - canvas.height / 2;
    let startX = Math.max(0, Math.floor(offsetX / TILE));
    let endX = Math.min(WORLD_W, Math.ceil((offsetX + canvas.width) / TILE) + 1);
    let startY = Math.max(0, Math.floor(offsetY / TILE));
    let endY = Math.min(WORLD_H, Math.ceil((offsetY + canvas.height) / TILE) + 1);

    let total = 0;
    for (let x = startX; x < endX; x++) {
        for (let y = startY; y < endY; y++) {
            let b = getBlock(x, y);
            if (b === BLOCKS.AIR) continue;
            total++;
            let sx = x * TILE - offsetX;
            let sy = y * TILE - offsetY;
            ctx.fillStyle = BLOCK_COLORS[b] || '#888';
            ctx.fillRect(sx, sy, TILE, TILE);
            if (b === BLOCKS.GRASS) {
                ctx.fillStyle = 'rgba(0,80,0,0.25)';
                ctx.fillRect(sx, sy + TILE - 4, TILE, 4);
            }
            if (b === BLOCKS.SNOW) {
                ctx.fillStyle = 'rgba(255,255,255,0.3)';
                ctx.fillRect(sx, sy, TILE, 3);
            }
            if (b === BLOCKS.WOOD) {
                ctx.fillStyle = 'rgba(0,0,0,0.2)';
                ctx.fillRect(sx + TILE / 2 - 1, sy, 2, TILE);
            }
            if (b === BLOCKS.LEAF) {
                ctx.fillStyle = 'rgba(0,200,50,0.15)';
                ctx.fillRect(sx, sy, TILE, TILE);
            }
            if (b === BLOCKS.IRON_ORE) {
                ctx.fillStyle = 'rgba(255,150,100,0.3)';
                ctx.fillRect(sx + 4, sy + 4, 8, 8);
                ctx.fillRect(sx + 18, sy + 18, 8, 8);
            }
            if (b === BLOCKS.GOLD_ORE) {
                ctx.fillStyle = 'rgba(255,215,0,0.3)';
                ctx.fillRect(sx + 8, sy + 6, 6, 6);
                ctx.fillRect(sx + 20, sy + 14, 6, 6);
            }
            ctx.strokeStyle = 'rgba(0,0,0,0.06)';
            ctx.lineWidth = 0.5;
            ctx.strokeRect(sx, sy, TILE, TILE);
        }
    }
    blockCountEl.textContent = total;

    // Руки
    let handX = canvas.width / 2;
    let handY = canvas.height - 80;
    let swing = player.handSwing * 40;
    let rot = player.handSwing * 0.6;

    ctx.save();
    ctx.translate(handX + 30, handY - 10);
    ctx.rotate(rot);
    ctx.fillStyle = '#4FC3F7';
    ctx.fillRect(0, 0, 60, 14);
    ctx.fillRect(50, -4, 16, 22);
    ctx.fillStyle = '#795548';
    ctx.fillRect(54, -2, 12, 4);
    ctx.fillStyle = '#78909C';
    ctx.fillRect(62, -8, 6, 16);
    ctx.restore();

    ctx.save();
    ctx.translate(handX - 30, handY - 10);
    ctx.rotate(-rot * 0.5);
    ctx.fillStyle = '#4FC3F7';
    ctx.fillRect(-60, 0, 50, 12);
    ctx.restore();

    let target = getTargetBlock();
    if (target && target.dist < 6) {
        targetInfoEl.textContent = BLOCK_NAMES[getBlock(target.x, target.y)] || '?';
    } else {
        targetInfoEl.textContent = '-';
    }
}

// ============================================================
// HOTBAR
// ============================================================
function updateHotbar() {
    hotbarEl.innerHTML = '';
    for (let i = 0; i < 9; i++) {
        let div = document.createElement('div');
        div.className = 'slot' + (i === player.selectedSlot ? ' active' : '');
        let item = player.inventory[i] || { type: BLOCKS.AIR, count: 0 };
        if (item.type !== BLOCKS.AIR && item.count > 0) {
            let name = BLOCK_NAMES[item.type] || '?';
            div.innerHTML = `<span class="name">${name.substring(0, 4)}</span><span class="count">${item.count}</span>`;
        } else {
            div.innerHTML = `<span class="name">·</span>`;
        }
        div.onclick = () => { player.selectedSlot = i; updateHotbar(); };
        hotbarEl.appendChild(div);
    }
}

// ============================================================
// ПРОВОДНИК
// ============================================================
let guideTimeout = null;

function showGuide(msg) {
    guideEl.textContent = '💡 ' + msg;
    guideEl.style.opacity = 1;
    clearTimeout(guideTimeout);
    guideTimeout = setTimeout(() => { guideEl.style.opacity = 0; }, 3000);
}

// ============================================================
// ГЛАВНЫЙ ЦИКЛ
// ============================================================
let lastTime = 0;

function gameLoop(timestamp) {
    let dt = Math.min((timestamp - lastTime) / 1000, 0.05);
    lastTime = timestamp;
    updatePlayer(dt);
    draw();
    updateHotbar();
    requestAnimationFrame(gameLoop);
}

// ============================================================
// ЗАПУСК
// ============================================================
generateWorld();
updateHotbar();
showGuide(isMobile ? '🎮 Джойстики для управления' : '💡 Кликни для блокировки мыши');
requestAnimationFrame(gameLoop);

window.addEventListener('resize', () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
});

console.log('🎮 SandWorld FPS запущена!');
</script>
</body>
</html>

Game Source: SandWorld FPS

Creator: TurboMeteor43

Libraries: none

Complexity: complex (875 lines, 30.1 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: sandworld-fps-turbometeor43" to link back to the original. Then publish at arcadelab.ai/publish.