Initial commit: AtomicEngine v0.2

- Rust/WASM physics engine (grid, material, physics, render_buffer)
- 16 material types (stone, sand, water, wood, fire, lava, glass, ice, oil, acid, steam, spark, smoke + player placeholder)
- Fire system (fuel-based, flammability-scaled consumption, spread, smoke/sparks)
- Lighting system (emission, flood-fill propagation, glow, reflection)
- Per-material pixel texturing (wood grain, dirt specks, stone noise, flame variation)
- WebGL2 renderer with camera (WASD move, mouse wheel zoom)
- Native x86 debug binary (identical dimensions, FPS counter)
- Large world 1024x768 with active-region simulation (camera + 200px margin)
- UI material palette + FPS display
This commit is contained in:
2026-07-09 11:13:39 +02:00
commit 1bce9da0c6
21 changed files with 3261 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
pkg/
dist/
node_modules/
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AtomicEngine</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
canvas {
display: block;
position: absolute;
top: 0;
left: 0;
}
#panel {
position: fixed;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2px;
padding: 4px 8px;
background: rgba(0,0,0,0.7);
border-radius: 6px;
z-index: 10;
font-family: monospace;
font-size: 10px;
color: #ccc;
user-select: none;
}
.mat {
display: flex;
flex-direction: column;
align-items: center;
padding: 3px 4px;
border-radius: 4px;
cursor: default;
min-width: 36px;
border: 1px solid transparent;
transition: background 0.1s;
}
.mat.active {
background: rgba(255,255,255,0.15);
border-color: rgba(255,255,255,0.5);
}
.mat .swatch {
width: 14px;
height: 14px;
border-radius: 2px;
margin-bottom: 2px;
}
.mat .label {
text-align: center;
line-height: 1;
}
.mat .key {
color: #888;
}
#info {
position: fixed;
top: 8px;
left: 8px;
color: #fff;
font-family: monospace;
font-size: 11px;
background: rgba(0,0,0,0.6);
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
}
#fps {
position: fixed;
top: 8px;
right: 8px;
color: #0f0;
font-family: monospace;
font-size: 14px;
font-weight: bold;
background: rgba(0,0,0,0.6);
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="info">AtomicEngine v0.2</div>
<div id="fps">60 FPS</div>
<div id="panel"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1041
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "atomic-engine-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.5",
"vite": "^5.4"
}
}
+15
View File
@@ -0,0 +1,15 @@
export class Camera {
x = 0;
y = 0;
zoom = 1;
targetZoom = 1;
update(dt: number) {
const speed = 10;
this.zoom += (this.targetZoom - this.zoom) * speed * dt;
}
setZoom(z: number) {
this.targetZoom = Math.max(0.1, Math.min(10, z));
}
}
+97
View File
@@ -0,0 +1,97 @@
import __wbg_init, {
init as wasm_init,
fill_rect,
fill_circle,
simulate,
update_render,
render_buffer_ptr,
render_buffer_len,
render_width,
render_height,
} from "../pkg/atomic_engine.js";
export const M = {
AIR: 0,
STONE: 1,
DIRT: 2,
SAND: 3,
WATER: 4,
WOOD: 5,
LAVA: 6,
PLAYER: 7,
STEAM: 8,
FIRE: 9,
GLASS: 10,
ICE: 11,
OIL: 12,
ACID: 13,
SPARK: 14,
SMOKE: 15,
} as const;
export const MATERIAL_NAMES: Record<number, string> = {
0: "Air",
1: "Stone",
2: "Dirt",
3: "Sand",
4: "Water",
5: "Wood",
6: "Lava",
7: "Player",
8: "Steam",
9: "Fire",
10: "Glass",
11: "Ice",
12: "Oil",
13: "Acid",
14: "Spark",
15: "Smoke",
};
export const MATERIAL_COLORS: Record<number, string> = {
0: "transparent",
1: "#787882",
2: "#654321",
3: "#c2b280",
4: "#1e3cc8",
5: "#5a3c1e",
6: "#ff6400",
7: "#32c832",
8: "#c8c8dc",
9: "#ffb41e",
10: "#b4d2dc",
11: "#b4dcff",
12: "#b48c28",
13: "#64ff3c",
14: "#ffb428",
15: "#a09b96",
};
export interface Engine {
fillRect: (x: number, y: number, w: number, h: number, material: number) => void;
fillCircle: (x: number, y: number, radius: number, material: number) => void;
simulate: (camX: number, camY: number, zoom: number) => void;
updateRender: (camX: number, camY: number, zoom: number) => void;
renderBufferPtr: () => number;
renderBufferLen: () => number;
renderWidth: () => number;
renderHeight: () => number;
memory: WebAssembly.Memory;
}
export async function createEngine(gridWidth: number, gridHeight: number): Promise<Engine> {
const wasm = await __wbg_init();
wasm_init(gridWidth, gridHeight);
return {
fillRect: (x, y, w, h, m) => fill_rect(x, y, w, h, m),
fillCircle: (x, y, r, m) => fill_circle(x, y, r, m),
simulate: (cx: number, cy: number, z: number) => simulate(cx, cy, z),
updateRender: (cx, cy, z) => update_render(cx, cy, z),
renderBufferPtr: () => render_buffer_ptr(),
renderBufferLen: () => render_buffer_len(),
renderWidth: () => render_width(),
renderHeight: () => render_height(),
memory: wasm.memory,
};
}
+25
View File
@@ -0,0 +1,25 @@
export class Input {
keys: Set<string> = new Set();
mouseX = 0;
mouseY = 0;
mouseDown = false;
wheel = 0;
constructor() {
window.addEventListener("keydown", (e) => this.keys.add(e.code));
window.addEventListener("keyup", (e) => this.keys.delete(e.code));
window.addEventListener("mousemove", (e) => {
this.mouseX = e.clientX;
this.mouseY = e.clientY;
});
window.addEventListener("mousedown", () => (this.mouseDown = true));
window.addEventListener("mouseup", () => (this.mouseDown = false));
window.addEventListener("wheel", (e) => {
this.wheel += e.deltaY > 0 ? -1 : 1;
});
}
isDown(key: string): boolean {
return this.keys.has(key);
}
}
+153
View File
@@ -0,0 +1,153 @@
import { createEngine, M, MATERIAL_NAMES, MATERIAL_COLORS } from "./engine";
import { Renderer } from "./renderer";
import { Input } from "./input";
import { Camera } from "./camera";
const GRID_WIDTH = 1024;
const GRID_HEIGHT = 768;
const PAINT_MATERIALS = [M.SAND, M.WATER, M.LAVA, M.WOOD, M.FIRE, M.ICE, M.OIL, M.ACID, M.STONE, M.DIRT, M.GLASS];
async function main() {
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const renderer = new Renderer(canvas);
const input = new Input();
const camera = new Camera();
camera.x = GRID_WIDTH / 2;
camera.y = GRID_HEIGHT / 2;
camera.zoom = 3;
const engine = await createEngine(GRID_WIDTH, GRID_HEIGHT);
engine.fillRect(0, 740, 1024, 28, M.STONE);
engine.fillRect(440, 720, 144, 20, M.WOOD);
engine.fillRect(460, 700, 104, 20, M.DIRT);
engine.fillCircle(490, 560, 25, M.SAND);
engine.fillCircle(520, 550, 20, M.SAND);
engine.fillRect(430, 650, 40, 30, M.WATER);
engine.fillRect(550, 650, 40, 25, M.LAVA);
engine.fillRect(505, 640, 10, 16, M.ICE);
engine.fillCircle(620, 580, 20, M.OIL);
engine.fillRect(480, 620, 64, 30, M.WOOD);
engine.fillRect(480, 600, 64, 20, M.FIRE);
engine.fillRect(300, 720, 40, 20, M.GLASS);
engine.fillCircle(200, 680, 18, M.SAND);
engine.fillRect(100, 700, 30, 40, M.WOOD);
engine.fillRect(750, 680, 60, 60, M.STONE);
engine.fillCircle(780, 660, 15, M.LAVA);
engine.fillRect(700, 720, 30, 20, M.ICE);
const rw = engine.renderWidth();
const rh = engine.renderHeight();
camera.x = 512;
camera.y = 600;
function resize() {
const dpr = window.devicePixelRatio || 1;
renderer.resize(window.innerWidth, window.innerHeight, dpr);
}
window.addEventListener("resize", resize);
resize();
let paintMat: number = M.SAND;
function screenToGrid(sx: number, sy: number) {
const mx = (sx / window.innerWidth - 0.5) * rw / camera.zoom + camera.x;
const my = (sy / window.innerHeight - 0.5) * rh / camera.zoom + camera.y;
return { x: Math.floor(mx), y: Math.floor(my) };
}
const panel = document.getElementById("panel")!;
function buildPanel() {
panel.innerHTML = "";
PAINT_MATERIALS.forEach((mat, idx) => {
const div = document.createElement("div");
div.className = "mat" + (mat === paintMat ? " active" : "");
div.dataset.mat = String(mat);
div.innerHTML = `<div class="swatch" style="background:${MATERIAL_COLORS[mat]}"></div><div class="label"><span class="key">${idx}</span> ${MATERIAL_NAMES[mat]}</div>`;
div.addEventListener("click", () => selectMat(mat));
panel.appendChild(div);
});
}
function selectMat(mat: number) {
paintMat = mat;
document.querySelectorAll(".mat").forEach((el) => {
el.classList.toggle("active", (el as HTMLElement).dataset.mat === String(mat));
});
}
buildPanel();
window.addEventListener("keydown", (e) => {
const idx = parseInt(e.key);
if (idx >= 0 && idx < PAINT_MATERIALS.length) {
selectMat(PAINT_MATERIALS[idx]);
}
});
let lastTime = performance.now();
let simAccum = 0;
const simStep = 1 / 60;
let fpsFrames = 0;
let fpsLast = performance.now();
const fpsEl = document.getElementById("fps")!;
function loop(now: number) {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
fpsFrames++;
if (now - fpsLast >= 500) {
const fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
fpsEl.textContent = `${fps} FPS`;
fpsFrames = 0;
fpsLast = now;
}
simAccum += dt;
while (simAccum >= simStep) {
simAccum -= simStep;
engine.simulate(
Math.round(camera.x),
Math.round(camera.y),
camera.zoom,
);
}
const speed = 200;
if (input.isDown("KeyA") || input.isDown("ArrowLeft")) camera.x -= speed * dt;
if (input.isDown("KeyD") || input.isDown("ArrowRight")) camera.x += speed * dt;
if (input.isDown("KeyW") || input.isDown("ArrowUp")) camera.y -= speed * dt;
if (input.isDown("KeyS") || input.isDown("ArrowDown")) camera.y += speed * dt;
if (input.isDown("Equal")) camera.setZoom(camera.zoom + 2 * dt);
if (input.isDown("Minus")) camera.setZoom(camera.zoom - 2 * dt);
if (input.wheel !== 0) {
camera.setZoom(camera.zoom + input.wheel * 0.5);
input.wheel = 0;
}
if (input.mouseDown) {
const g = screenToGrid(input.mouseX, input.mouseY);
engine.fillCircle(g.x, g.y, 6, paintMat);
}
camera.update(dt);
engine.updateRender(
Math.round(camera.x),
Math.round(camera.y),
camera.zoom,
);
const ptr = engine.renderBufferPtr();
const len = engine.renderBufferLen();
const buf = new Uint8Array(engine.memory.buffer, ptr, len).slice(0);
renderer.uploadTexture(buf, rw, rh);
renderer.render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
main();
+102
View File
@@ -0,0 +1,102 @@
export class Renderer {
private gl: WebGL2RenderingContext;
private texture: WebGLTexture | null = null;
private program: WebGLProgram | null = null;
private vao: WebGLVertexArrayObject | null = null;
constructor(canvas: HTMLCanvasElement) {
const gl = canvas.getContext("webgl2", {
antialias: false,
alpha: false,
premultipliedAlpha: false,
});
if (!gl) throw new Error("WebGL2 not supported");
this.gl = gl;
this.init();
}
private init() {
const gl = this.gl;
const vs = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vs, `#version 300 es
in vec2 a_pos;
in vec2 a_uv;
out vec2 v_uv;
void main() {
gl_Position = vec4(a_pos, 0.0, 1.0);
v_uv = a_uv;
}
`);
gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fs, `#version 300 es
precision mediump float;
in vec2 v_uv;
out vec4 fragColor;
uniform sampler2D u_tex;
void main() {
fragColor = texture(u_tex, v_uv);
}
`);
gl.compileShader(fs);
this.program = gl.createProgram()!;
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
const vertices = new Float32Array([
-1, -1, 0, 1,
1, -1, 1, 1,
1, 1, 1, 0,
-1, -1, 0, 1,
1, 1, 1, 0,
-1, 1, 0, 0,
]);
this.vao = gl.createVertexArray();
gl.bindVertexArray(this.vao);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const stride = 4 * 4;
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 2 * 4);
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
uploadTexture(data: Uint8Array, width: number, height: number) {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
}
render() {
const gl = this.gl;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.program);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
resize(cssWidth: number, cssHeight: number, dpr: number) {
const canvas = this.gl.canvas as HTMLCanvasElement;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = cssWidth + "px";
canvas.style.height = cssHeight + "px";
this.gl.viewport(0, 0, canvas.width, canvas.height);
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
build: {
target: "es2020",
},
});