Created
February 13, 2026 10:57
-
-
Save xqm32/3c5c08aca4a027bb84dea9f6e48bdbe4 to your computer and use it in GitHub Desktop.
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="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