Skip to content

Instantly share code, notes, and snippets.

@xqm32
Created February 13, 2026 10:57
Show Gist options
  • Select an option

  • Save xqm32/3c5c08aca4a027bb84dea9f6e48bdbe4 to your computer and use it in GitHub Desktop.

Select an option

Save xqm32/3c5c08aca4a027bb84dea9f6e48bdbe4 to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Micro Drift (Osmos-like)</title>
<style>
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: #05070d;
color: #e8f0ff;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
}
#game {
width: 100vw;
height: 100vh;
display: block;
cursor: crosshair;
}
#hud {
position: fixed;
top: 14px;
left: 14px;
z-index: 5;
font-size: 13px;
line-height: 1.5;
background: rgba(8, 12, 22, 0.55);
border: 1px solid rgba(190, 210, 255, 0.18);
border-radius: 10px;
padding: 10px 12px;
backdrop-filter: blur(6px);
pointer-events: none;
white-space: pre-line;
}
#centerMsg {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
z-index: 6;
pointer-events: none;
color: #f2f6ff;
text-shadow: 0 0 18px rgba(115, 170, 255, 0.35);
padding: 20px;
}
#centerMsg .card {
background: rgba(8, 12, 22, 0.5);
border: 1px solid rgba(190, 210, 255, 0.2);
border-radius: 14px;
padding: 18px 22px;
max-width: 520px;
line-height: 1.6;
backdrop-filter: blur(8px);
}
#centerMsg .title {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
}
#centerMsg .sub {
opacity: 0.9;
font-size: 14px;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud"></div>
<div id="centerMsg"><div class="card"><div class="title">Micro Drift</div><div class="sub">移动鼠标瞄准,按住左键喷射推进(反方向移动)<br>吞噬更小的细胞,避开更大的细胞<br>滚轮缩放视角,点击任意处开始</div></div></div>
<script>
const canvas = document.getElementById('game');
const hud = document.getElementById('hud');
const centerMsg = document.getElementById('centerMsg');
const gl = canvas.getContext('webgl', { antialias: true, alpha: false });
if (!gl) {
centerMsg.innerHTML = '<div class="card"><div class="title">浏览器不支持 WebGL</div><div class="sub">请用支持 WebGL 的现代浏览器打开。</div></div>';
throw new Error('WebGL not supported');
}
const vsrc = `
attribute vec2 a_pos;
attribute float a_radius;
attribute vec3 a_color;
uniform vec2 u_cam;
uniform float u_zoom;
uniform vec2 u_res;
varying vec3 v_color;
varying float v_size;
void main() {
vec2 rel = (a_pos - u_cam) * u_zoom;
vec2 clip = vec2(rel.x / (u_res.x * 0.5), -rel.y / (u_res.y * 0.5));
gl_Position = vec4(clip, 0.0, 1.0);
gl_PointSize = max(2.0, a_radius * u_zoom * 2.0);
v_color = a_color;
v_size = gl_PointSize;
}
`;
const fsrc = `
precision mediump float;
varying vec3 v_color;
varying float v_size;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float d = length(uv);
if (d > 1.0) discard;
float rim = smoothstep(1.0, 0.80, d);
float glow = smoothstep(1.0, 0.0, d);
float shade = 1.0 - d * 0.35;
vec3 col = v_color * shade + v_color * 0.18 * glow;
gl_FragColor = vec4(col, rim);
}
`;
function compile(type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader));
}
return shader;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, vsrc));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fsrc));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(prog));
}
gl.useProgram(prog);
const a_pos = gl.getAttribLocation(prog, 'a_pos');
const a_radius = gl.getAttribLocation(prog, 'a_radius');
const a_color = gl.getAttribLocation(prog, 'a_color');
const u_cam = gl.getUniformLocation(prog, 'u_cam');
const u_zoom = gl.getUniformLocation(prog, 'u_zoom');
const u_res = gl.getUniformLocation(prog, 'u_res');
const posBuf = gl.createBuffer();
const radiusBuf = gl.createBuffer();
const colorBuf = gl.createBuffer();
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const WORLD_RADIUS = 2100;
const FOOD_TARGET = 185;
const PELLET_LIFE = 14;
const mouse = {
x: 0,
y: 0,
down: false,
worldX: 0,
worldY: 0,
};
const state = {
blobs: [],
playerId: -1,
running: false,
win: false,
lose: false,
camX: 0,
camY: 0,
zoom: 1,
zoomTarget: 1,
thrustCd: 0,
lastTs: 0,
};
function rand(min, max) {
return Math.random() * (max - min) + min;
}
function pickColor(kind) {
if (kind === 'player') return [0.45, 0.86, 1.00];
if (kind === 'hunter') {
const palette = [
[0.99, 0.52, 0.62],
[0.95, 0.66, 0.32],
[0.82, 0.52, 0.95],
];
return palette[(Math.random() * palette.length) | 0];
}
const t = Math.random();
return [0.35 + t * 0.35, 0.45 + t * 0.45, 0.70 + t * 0.25];
}
function radiusOf(mass) {
return Math.sqrt(Math.max(1, mass));
}
function makeBlob(x, y, mass, kind, objective = true) {
return {
x,
y,
vx: rand(-9, 9),
vy: rand(-9, 9),
mass,
kind,
objective,
color: pickColor(kind),
alive: true,
life: Infinity,
};
}
function randomPos() {
const a = Math.random() * Math.PI * 2;
const d = Math.sqrt(Math.random()) * (WORLD_RADIUS - 140);
return { x: Math.cos(a) * d, y: Math.sin(a) * d };
}
function spawnFood(count) {
for (let i = 0; i < count; i++) {
const p = randomPos();
const m = rand(9, 42);
state.blobs.push(makeBlob(p.x, p.y, m, 'food', true));
}
}
function resetGame() {
state.blobs = [];
state.win = false;
state.lose = false;
state.running = true;
state.lastTs = 0;
state.zoomTarget = 1;
state.zoom = 1;
state.camX = 0;
state.camY = 0;
state.thrustCd = 0;
const player = makeBlob(0, 0, 760, 'player', false);
player.vx = 0;
player.vy = 0;
state.playerId = 0;
state.blobs.push(player);
spawnFood(120);
for (let i = 0; i < 20; i++) {
const p = randomPos();
const m = rand(85, 280);
state.blobs.push(makeBlob(p.x, p.y, m, 'drifter', true));
}
for (let i = 0; i < 8; i++) {
const p = randomPos();
const m = rand(320, 760);
const h = makeBlob(p.x, p.y, m, 'hunter', true);
h.vx *= 0.55;
h.vy *= 0.55;
state.blobs.push(h);
}
centerMsg.style.display = 'none';
}
function resize() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
}
function worldFromMouse() {
const p = state.blobs[state.playerId];
if (!p || !p.alive) return;
const cx = canvas.width * 0.5;
const cy = canvas.height * 0.5;
const wx = state.camX + (mouse.x - cx) / state.zoom;
const wy = state.camY + (mouse.y - cy) / state.zoom;
mouse.worldX = wx;
mouse.worldY = wy;
}
function doThrust(dt) {
const p = state.blobs[state.playerId];
if (!p || !p.alive || !mouse.down) return;
state.thrustCd -= dt;
if (state.thrustCd > 0) return;
if (p.mass < 40) return;
state.thrustCd = 0.048;
const dx = mouse.worldX - p.x;
const dy = mouse.worldY - p.y;
const dist = Math.hypot(dx, dy) || 1;
const nx = dx / dist;
const ny = dy / dist;
const before = p.mass;
const ejectMass = Math.max(2.0, Math.min(7.2, p.mass * 0.013));
p.mass = Math.max(18, p.mass - ejectMass);
const impulse = (ejectMass * 520) / Math.max(20, before * 0.35);
p.vx -= nx * impulse;
p.vy -= ny * impulse;
const pr = radiusOf(p.mass);
const pellet = makeBlob(
p.x + nx * (pr + 5),
p.y + ny * (pr + 5),
ejectMass * 0.95,
'food',
false
);
pellet.vx = p.vx + nx * 320;
pellet.vy = p.vy + ny * 320;
pellet.life = PELLET_LIFE;
state.blobs.push(pellet);
}
function aiStep(blob, dt, player) {
if (!blob.alive || blob.kind === 'player' || blob.kind === 'food') return;
let tx = 0;
let ty = 0;
let fleeX = 0;
let fleeY = 0;
let huntScore = 0;
let fearScore = 0;
const br = radiusOf(blob.mass);
for (let i = 0; i < state.blobs.length; i++) {
const other = state.blobs[i];
if (!other.alive || other === blob) continue;
const ox = other.x - blob.x;
const oy = other.y - blob.y;
const d = Math.hypot(ox, oy) + 0.0001;
const or = radiusOf(other.mass);
if (other.mass < blob.mass * 0.88) {
const score = (blob.mass - other.mass) / d;
if (score > huntScore) {
huntScore = score;
tx = ox / d;
ty = oy / d;
}
} else if (other.mass > blob.mass * 1.12 && d < 420 + br + or) {
const score = (other.mass - blob.mass) / d;
if (score > fearScore) {
fearScore = score;
fleeX = -ox / d;
fleeY = -oy / d;
}
}
}
if (blob.kind === 'hunter' && player && player.alive) {
const px = player.x - blob.x;
const py = player.y - blob.y;
const d = Math.hypot(px, py) + 0.0001;
if (player.mass < blob.mass * 0.95) {
tx = px / d;
ty = py / d;
huntScore += 2.2 / Math.max(1, d * 0.01);
} else if (player.mass > blob.mass * 1.08) {
fleeX = -px / d;
fleeY = -py / d;
fearScore += 3.0 / Math.max(1, d * 0.01);
}
}
let ax = 0;
let ay = 0;
if (fearScore > 0.0001) {
ax += fleeX * 28;
ay += fleeY * 28;
} else if (huntScore > 0.0001) {
ax += tx * (blob.kind === 'hunter' ? 18 : 13);
ay += ty * (blob.kind === 'hunter' ? 18 : 13);
} else {
const t = performance.now() * 0.001 + blob.mass;
ax += Math.cos(t) * 6;
ay += Math.sin(t * 1.4) * 6;
}
const accelScale = 1 / Math.max(8, radiusOf(blob.mass));
blob.vx += ax * accelScale * dt;
blob.vy += ay * accelScale * dt;
}
function integrate(blob, dt) {
const r = radiusOf(blob.mass);
blob.x += blob.vx * dt;
blob.y += blob.vy * dt;
const drag = 0.15 / Math.max(1, r * 0.10);
blob.vx *= Math.max(0, 1 - drag * dt);
blob.vy *= Math.max(0, 1 - drag * dt);
const d0 = Math.hypot(blob.x, blob.y);
const maxD = WORLD_RADIUS - r;
if (d0 > maxD) {
const nx = blob.x / (d0 || 1);
const ny = blob.y / (d0 || 1);
blob.x = nx * maxD;
blob.y = ny * maxD;
const vn = blob.vx * nx + blob.vy * ny;
if (vn > 0) {
blob.vx -= vn * 1.7 * nx;
blob.vy -= vn * 1.7 * ny;
}
}
if (blob.life !== Infinity) {
blob.life -= dt;
if (blob.life <= 0 || blob.mass < 1.2) blob.alive = false;
}
if (blob.kind !== 'player') {
const decay = 0.00035 * Math.sqrt(blob.mass);
blob.mass = Math.max(1, blob.mass * (1 - decay * dt));
}
}
function absorbStep() {
const arr = state.blobs;
for (let i = 0; i < arr.length; i++) {
const a = arr[i];
if (!a.alive) continue;
for (let j = i + 1; j < arr.length; j++) {
const b = arr[j];
if (!b.alive) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const ar = radiusOf(a.mass);
const br = radiusOf(b.mass);
if (d > ar + br * 0.72 && d > br + ar * 0.72) continue;
let big = a;
let small = b;
if (b.mass > a.mass) {
big = b;
small = a;
}
if (big.mass < small.mass * 1.08) continue;
const bigR = radiusOf(big.mass);
const smallR = radiusOf(small.mass);
const dist = Math.hypot(big.x - small.x, big.y - small.y);
if (dist > bigR - smallR * 0.18) continue;
big.mass += small.mass * 0.98;
big.vx = (big.vx * big.mass + small.vx * small.mass * 0.15) / (big.mass + small.mass * 0.15);
big.vy = (big.vy * big.mass + small.vy * small.mass * 0.15) / (big.mass + small.mass * 0.15);
small.alive = false;
}
}
}
function cleanupAndSpawn() {
state.blobs = state.blobs.filter(b => b.alive && b.mass > 0.8);
let aliveFood = 0;
for (const b of state.blobs) {
if (b.kind === 'food') aliveFood++;
}
if (aliveFood < FOOD_TARGET) {
spawnFood(Math.min(8, FOOD_TARGET - aliveFood));
}
const player = state.blobs.find(b => b.kind === 'player');
state.playerId = player ? state.blobs.indexOf(player) : -1;
if (!player || !player.alive) {
state.lose = true;
state.running = false;
} else {
let remainingObjectives = 0;
for (const b of state.blobs) {
if (b.alive && b.objective) remainingObjectives++;
}
if (remainingObjectives === 0) {
state.win = true;
state.running = false;
}
}
}
function updateCamera(dt) {
const p = state.blobs[state.playerId];
if (!p || !p.alive) return;
state.camX += (p.x - state.camX) * Math.min(1, dt * 3.8);
state.camY += (p.y - state.camY) * Math.min(1, dt * 3.8);
const target = (0.92 / Math.max(0.5, radiusOf(p.mass) / 42)) * state.zoomTarget;
state.zoom += (target - state.zoom) * Math.min(1, dt * 4.2);
state.zoom = Math.max(0.16, Math.min(2.5, state.zoom));
}
function updateHUD() {
const p = state.blobs[state.playerId];
const mass = p && p.alive ? p.mass.toFixed(1) : '0';
let objective = 0;
for (const b of state.blobs) {
if (b.alive && b.objective) objective++;
}
const zoomPercent = Math.round(state.zoom * 100);
hud.textContent = `质量: ${mass}\n剩余目标: ${objective}\n缩放: ${zoomPercent}%\n操作: 鼠标移动瞄准 / 左键按住喷射 / 滚轮缩放`;
if (!state.running) {
if (state.win) {
centerMsg.style.display = 'flex';
centerMsg.innerHTML = '<div class="card"><div class="title">胜利</div><div class="sub">你吞噬了所有目标细胞。<br>点击任意处重新开始。</div></div>';
} else if (state.lose) {
centerMsg.style.display = 'flex';
centerMsg.innerHTML = '<div class="card"><div class="title">失败</div><div class="sub">你被更大的细胞吞噬了。<br>点击任意处重新开始。</div></div>';
}
}
}
function render() {
gl.clearColor(0.03, 0.04, 0.08, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
const visible = [];
const halfW = canvas.width * 0.5 / state.zoom;
const halfH = canvas.height * 0.5 / state.zoom;
for (const b of state.blobs) {
if (!b.alive) continue;
const r = radiusOf(b.mass);
if (Math.abs(b.x - state.camX) > halfW + r * 1.2) continue;
if (Math.abs(b.y - state.camY) > halfH + r * 1.2) continue;
visible.push(b);
}
visible.sort((a, b) => a.mass - b.mass);
const n = visible.length;
const pos = new Float32Array(n * 2);
const rad = new Float32Array(n);
const col = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
const b = visible[i];
pos[i * 2] = b.x;
pos[i * 2 + 1] = b.y;
rad[i] = radiusOf(b.mass);
col[i * 3] = b.color[0];
col[i * 3 + 1] = b.color[1];
col[i * 3 + 2] = b.color[2];
}
gl.uniform2f(u_cam, state.camX, state.camY);
gl.uniform1f(u_zoom, state.zoom);
gl.uniform2f(u_res, canvas.width, canvas.height);
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
gl.bufferData(gl.ARRAY_BUFFER, pos, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(a_pos);
gl.vertexAttribPointer(a_pos, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, radiusBuf);
gl.bufferData(gl.ARRAY_BUFFER, rad, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(a_radius);
gl.vertexAttribPointer(a_radius, 1, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuf);
gl.bufferData(gl.ARRAY_BUFFER, col, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(a_color);
gl.vertexAttribPointer(a_color, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, n);
}
function tick(ts) {
resize();
if (!state.lastTs) state.lastTs = ts;
let dt = (ts - state.lastTs) * 0.001;
state.lastTs = ts;
if (!Number.isFinite(dt) || dt <= 0) dt = 0.016;
dt = Math.min(0.04, dt);
worldFromMouse();
if (state.running) {
const p = state.blobs[state.playerId];
doThrust(dt);
for (const b of state.blobs) {
aiStep(b, dt, p);
}
for (const b of state.blobs) {
integrate(b, dt);
}
absorbStep();
cleanupAndSpawn();
}
updateCamera(dt);
render();
updateHUD();
requestAnimationFrame(tick);
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const dprX = canvas.width / rect.width;
const dprY = canvas.height / rect.height;
mouse.x = (e.clientX - rect.left) * dprX;
mouse.y = (e.clientY - rect.top) * dprY;
});
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
mouse.down = true;
if (!state.running) {
resetGame();
}
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0) mouse.down = false;
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const dir = Math.sign(e.deltaY);
state.zoomTarget *= dir > 0 ? 0.92 : 1.08;
state.zoomTarget = Math.max(0.55, Math.min(1.85, state.zoomTarget));
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
resetGame();
centerMsg.style.display = 'flex';
centerMsg.innerHTML = '<div class="card"><div class="title">Micro Drift</div><div class="sub">移动鼠标瞄准,按住左键喷射推进(反方向移动)<br>吞噬更小的细胞,避开更大的细胞<br>滚轮缩放视角,点击任意处开始</div></div>';
state.running = false;
requestAnimationFrame(tick);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment