Created
May 18, 2025 13:24
-
-
Save DerGoogler/7720c7c944f9b92f73ee7c3ee7d4e640 to your computer and use it in GitHub Desktop.
WebUI X Snake WebGPU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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>Snake Game</title> | |
<!-- Window Safe Area Insets --> | |
<link rel="stylesheet" type="text/css" href="https://mui.kernelsu.org/internal/insets.css" /> | |
<!-- App Theme which the user has currently selected --> | |
<link rel="stylesheet" type="text/css" href="https://mui.kernelsu.org/internal/colors.css" /> | |
<style> | |
html, | |
body { | |
margin: 0; | |
padding: 0; | |
overflow: hidden; | |
background: var(--background); | |
width: 100%; | |
height: 100%; | |
} | |
canvas { | |
display: block; | |
width: 100vw; | |
height: calc( | |
100vh - var(--window-inset-top) - var(--window-inset-bottom) | |
); | |
margin-top: var(--window-inset-top); | |
margin-bottom: var(--window-inset-bottom); | |
outline-style: dotted; | |
outline-color: var(--primary); | |
outline-width: 5px; | |
} | |
.score { | |
margin-top: var(--window-inset-top); | |
position: absolute; | |
z-index: 10; | |
font-family: Arial, sans-serif; | |
top: 10px; | |
left: 10px; | |
font-size: 20pt; | |
color: var(--onBackground); | |
display: flex; | |
flex-direction: row; | |
justify-content: space-between; | |
justify-items: flex-end; | |
align-items: center; | |
width: calc(100% - 20px); | |
} | |
</style> | |
</head> | |
<body> | |
<canvas id="gameCanvas"></canvas> | |
<div class="score"> | |
<div id="score"></div> | |
<svg | |
id="restart" | |
xmlns="http://www.w3.org/2000/svg" | |
width="30" | |
height="30" | |
viewBox="0 0 24 24" | |
fill="none" | |
stroke="currentColor" | |
stroke-width="2" | |
stroke-linecap="round" | |
stroke-linejoin="round" | |
class="icon icon-tabler icons-tabler-outline icon-tabler-rotate-rectangle" | |
> | |
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> | |
<path | |
d="M10.09 4.01l.496 -.495a2 2 0 0 1 2.828 0l7.071 7.07a2 2 0 0 1 0 2.83l-7.07 7.07a2 2 0 0 1 -2.83 0l-7.07 -7.07a2 2 0 0 1 0 -2.83l3.535 -3.535h-3.988" | |
/> | |
<path d="M7.05 11.038v-3.988" /> | |
</svg> | |
</div> | |
<script type="module"> | |
const root = document.documentElement; | |
const topInset = getComputedStyle(root) | |
.getPropertyValue("--window-inset-top") | |
.trim(); | |
const bottomInset = getComputedStyle(root) | |
.getPropertyValue("--window-inset-bottom") | |
.trim(); | |
const backgroundColor = getComputedStyle(root) | |
.getPropertyValue("--background") | |
.trim(); | |
const primaryColor = getComputedStyle(root) | |
.getPropertyValue("--primary") | |
.trim(); | |
const errorContainerColor = getComputedStyle(root) | |
.getPropertyValue("--errorContainer") | |
.trim(); | |
const topInsetInt = parseInt(topInset.replace("px", "")); | |
const bottomInsetInt = parseInt(bottomInset.replace("px", "")); | |
async function initWebGPU() { | |
if (!navigator.gpu) { | |
alert("WebGPU is not supported on this device."); | |
return null; | |
} | |
const restartButton = document.getElementById("restart"); | |
restartButton.addEventListener("click", () => { | |
initGame(); | |
}); | |
const adapter = await navigator.gpu.requestAdapter(); | |
const device = await adapter.requestDevice(); | |
const canvas = document.getElementById("gameCanvas"); | |
const context = canvas.getContext("webgpu"); | |
const format = navigator.gpu.getPreferredCanvasFormat(); | |
context.configure({ | |
device, | |
format, | |
alphaMode: "opaque", | |
}); | |
const shaderModule = device.createShaderModule({ | |
code: ` | |
struct VertexOutput { | |
@builtin(position) Position : vec4<f32>, | |
@location(0) fragColor : vec3<f32>, | |
}; | |
@vertex | |
fn vmain(@location(0) position: vec2<f32>, | |
@location(1) color: vec3<f32>) -> VertexOutput { | |
var output: VertexOutput; | |
output.Position = vec4<f32>(position, 0.0, 1.0); | |
output.fragColor = color; | |
return output; | |
} | |
@fragment | |
fn pmain(@location(0) color: vec3<f32>) -> @location(0) vec4<f32> { | |
return vec4<f32>(color, 1.0); | |
} | |
`, | |
}); | |
const pipeline = device.createRenderPipeline({ | |
layout: "auto", | |
vertex: { | |
module: shaderModule, | |
entryPoint: "vmain", | |
buffers: [ | |
{ | |
arrayStride: 5 * 4, | |
attributes: [ | |
{ shaderLocation: 0, offset: 0, format: "float32x2" }, | |
{ shaderLocation: 1, offset: 2 * 4, format: "float32x3" }, | |
], | |
}, | |
], | |
}, | |
fragment: { | |
module: shaderModule, | |
entryPoint: "pmain", | |
targets: [{ format }], | |
}, | |
primitive: { | |
topology: "triangle-list", | |
}, | |
}); | |
const gpuBuffers = []; | |
function createRectangleVertices(x, y, width, height, color) { | |
const x1 = (x / canvas.width) * 2 - 1; | |
const y1 = 1 - (y / canvas.height) * 2; | |
const x2 = ((x + width) / canvas.width) * 2 - 1; | |
const y2 = 1 - ((y + height) / canvas.height) * 2; | |
return new Float32Array([ | |
x1, | |
y1, | |
...color, | |
x2, | |
y1, | |
...color, | |
x1, | |
y2, | |
...color, | |
x1, | |
y2, | |
...color, | |
x2, | |
y1, | |
...color, | |
x2, | |
y2, | |
...color, | |
]); | |
} | |
const createObject = (id, x, y, w, h, c) => { | |
const obj = createRectangleVertices(x, y, w, h, c); | |
const buffer = device.createBuffer({ | |
size: obj.byteLength, | |
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, | |
}); | |
device.queue.writeBuffer(buffer, 0, obj); | |
gpuBuffers[id] = { buffer, length: obj.length, color: c }; | |
}; | |
const updateObject = (id, x, y, w, h, c) => { | |
const obj = createRectangleVertices(x, y, w, h, c); | |
device.queue.writeBuffer(gpuBuffers[id].buffer, 0, obj); | |
}; | |
const drawObjects = () => { | |
const commandEncoder = device.createCommandEncoder(); | |
const textureView = context.getCurrentTexture().createView(); | |
const renderPass = commandEncoder.beginRenderPass({ | |
colorAttachments: [ | |
{ | |
view: textureView, | |
clearValue: hexToClearValue(backgroundColor), | |
loadOp: "clear", | |
storeOp: "store", | |
}, | |
], | |
}); | |
renderPass.setPipeline(pipeline); | |
for (let key in gpuBuffers) { | |
renderPass.setVertexBuffer(0, gpuBuffers[key].buffer); | |
renderPass.draw(gpuBuffers[key].length / 5); | |
} | |
renderPass.end(); | |
device.queue.submit([commandEncoder.finish()]); | |
}; | |
return { device, createObject, updateObject, drawObjects }; | |
} | |
const canvas = document.getElementById("gameCanvas"); | |
canvas.width = window.innerWidth; | |
canvas.height = window.innerHeight - (topInsetInt + bottomInsetInt); | |
window.addEventListener("resize", () => { | |
canvas.width = window.innerWidth; | |
canvas.height = window.innerHeight - (topInsetInt + bottomInsetInt); | |
}); | |
const { device, createObject, updateObject, drawObjects } = | |
await initWebGPU(); | |
// Game logic | |
const GRID_SIZE = 20; | |
let snake = []; | |
let food = {}; | |
let direction = "right"; | |
let changingDirection = false; | |
let score = 0; | |
let gameOver = false; | |
const FOOD_COLOR = hexToRGBAArray(errorContainerColor); | |
const SNAKE_COLOR = hexToRGBAArray(primaryColor); | |
function generateFoodPosition() { | |
let position; | |
do { | |
position = { | |
x: Math.floor(Math.random() * (canvas.width / GRID_SIZE)), | |
y: Math.floor(Math.random() * (canvas.height / GRID_SIZE)), | |
}; | |
} while (snake.some((p) => p.x === position.x && p.y === position.y)); | |
return position; | |
} | |
function moveSnake() { | |
for (let i = snake.length - 1; i > 0; i--) { | |
snake[i] = { ...snake[i - 1] }; | |
} | |
switch (direction) { | |
case "up": | |
snake[0].y--; | |
break; | |
case "down": | |
snake[0].y++; | |
break; | |
case "left": | |
snake[0].x--; | |
break; | |
case "right": | |
snake[0].x++; | |
break; | |
} | |
changingDirection = false; | |
} | |
function isGameOver() { | |
const head = snake[0]; | |
if ( | |
head.x < 0 || | |
head.y < 0 || | |
head.x >= Math.floor(canvas.width / GRID_SIZE) || | |
head.y >= Math.floor(canvas.height / GRID_SIZE) || | |
snake.slice(1).some((p) => p.x === head.x && p.y === head.y) | |
) { | |
return true; | |
} | |
return false; | |
} | |
function updateGame() { | |
if (gameOver) return; | |
moveSnake(); | |
if (snake[0].x === food.x && snake[0].y === food.y) { | |
snake.push({ ...snake[snake.length - 1] }); | |
score += 10; | |
food = generateFoodPosition(); | |
} | |
if (isGameOver()) { | |
gameOver = true; | |
alert("Game Over! Wanna restart the game?"); | |
} | |
drawGame(); | |
} | |
function drawGame() { | |
updateObject( | |
"FOOD", | |
food.x * GRID_SIZE, | |
food.y * GRID_SIZE, | |
GRID_SIZE, | |
GRID_SIZE, | |
FOOD_COLOR | |
); | |
snake.forEach((part, i) => { | |
updateObject( | |
"BODY_" + i, | |
part.x * GRID_SIZE, | |
part.y * GRID_SIZE, | |
GRID_SIZE, | |
GRID_SIZE, | |
SNAKE_COLOR | |
); | |
}); | |
document.getElementById("score").innerText = `Score: ${score}`; | |
drawObjects(); | |
} | |
function initGame() { | |
for (let i = 0; i < 100; i++) { | |
createObject( | |
"BODY_" + i, | |
-100, | |
-100, | |
GRID_SIZE, | |
GRID_SIZE, | |
SNAKE_COLOR | |
); | |
} | |
createObject("FOOD", -100, -100, GRID_SIZE, GRID_SIZE, FOOD_COLOR); | |
snake = [ | |
{ x: 5, y: 5 }, | |
{ x: 4, y: 5 }, | |
{ x: 3, y: 5 }, | |
]; | |
food = generateFoodPosition(); | |
score = 0; | |
gameOver = false; | |
setInterval(updateGame, 150); | |
} | |
// Keyboard controls | |
document.addEventListener("keydown", (e) => { | |
if (changingDirection) return; | |
changingDirection = true; | |
switch (e.key) { | |
case "ArrowUp": | |
if (direction !== "down") direction = "up"; | |
break; | |
case "ArrowDown": | |
if (direction !== "up") direction = "down"; | |
break; | |
case "ArrowLeft": | |
if (direction !== "right") direction = "left"; | |
break; | |
case "ArrowRight": | |
if (direction !== "left") direction = "right"; | |
break; | |
} | |
}); | |
// Swipe controls | |
let touchStartX = 0, | |
touchStartY = 0; | |
canvas.addEventListener("touchstart", (e) => { | |
touchStartX = e.touches[0].clientX; | |
touchStartY = e.touches[0].clientY; | |
}); | |
canvas.addEventListener("touchend", (e) => { | |
const dx = e.changedTouches[0].clientX - touchStartX; | |
const dy = e.changedTouches[0].clientY - touchStartY; | |
if (Math.abs(dx) > Math.abs(dy)) { | |
if (dx > 30 && direction !== "left") direction = "right"; | |
else if (dx < -30 && direction !== "right") direction = "left"; | |
} else { | |
if (dy > 30 && direction !== "up") direction = "down"; | |
else if (dy < -30 && direction !== "down") direction = "up"; | |
} | |
}); | |
initGame(); | |
function hexToClearValue(hex) { | |
if (!/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) { | |
throw new Error( | |
"Invalid hex color. Use #rrggbb or #rrggbbaa format." | |
); | |
} | |
// Remove the '#' and split into parts | |
const value = hex.slice(1); | |
const hasAlpha = value.length === 8; | |
const r = parseInt(value.slice(0, 2), 16) / 255; | |
const g = parseInt(value.slice(2, 4), 16) / 255; | |
const b = parseInt(value.slice(4, 6), 16) / 255; | |
const a = hasAlpha ? parseInt(value.slice(6, 8), 16) / 255 : 1; | |
return { r, g, b, a }; | |
} | |
function hexToRGBAArray(hex) { | |
if (!/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(hex)) { | |
throw new Error( | |
"Invalid hex color. Use #rrggbb or #rrggbbaa format." | |
); | |
} | |
const value = hex.slice(1); | |
const r = parseInt(value.slice(0, 2), 16) / 255; | |
const g = parseInt(value.slice(2, 4), 16) / 255; | |
const b = parseInt(value.slice(4, 6), 16) / 255; | |
return [r, g, b]; | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment