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:
@@ -0,0 +1,3 @@
|
||||
pkg/
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -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>
|
||||
Generated
+1041
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user