620 lines
17 KiB
HTML
620 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>物种进化模拟器</title>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body {
|
|
background:#080810;
|
|
color:#aaa;
|
|
font-family:'Courier New',monospace;
|
|
display:flex;
|
|
flex-direction:column;
|
|
align-items:center;
|
|
padding:10px;
|
|
min-height:100vh;
|
|
}
|
|
h1 {
|
|
color:#ccc;
|
|
font-size:13px;
|
|
letter-spacing:6px;
|
|
text-transform:uppercase;
|
|
margin-bottom:8px;
|
|
opacity:0.6;
|
|
}
|
|
.layout {
|
|
display:flex;
|
|
gap:8px;
|
|
align-items:flex-start;
|
|
}
|
|
.left { display:flex; flex-direction:column; gap:5px; }
|
|
#game { border:1px solid #1a1a28; cursor:crosshair; display:block; }
|
|
#graph { border:1px solid #1a1a28; display:block; }
|
|
.sidebar {
|
|
width:185px;
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:5px;
|
|
}
|
|
.card {
|
|
background:#0c0c18;
|
|
border:1px solid #1a1a28;
|
|
border-radius:3px;
|
|
padding:7px;
|
|
}
|
|
.card-title {
|
|
font-size:8px;
|
|
letter-spacing:3px;
|
|
text-transform:uppercase;
|
|
color:#333;
|
|
margin-bottom:5px;
|
|
}
|
|
.big-num {
|
|
font-size:26px;
|
|
color:#ddd;
|
|
font-weight:bold;
|
|
text-align:center;
|
|
letter-spacing:2px;
|
|
}
|
|
.big-label {
|
|
font-size:8px;
|
|
color:#2a2a3a;
|
|
text-align:center;
|
|
letter-spacing:3px;
|
|
text-transform:uppercase;
|
|
}
|
|
.stat-row {
|
|
display:flex;
|
|
justify-content:space-between;
|
|
font-size:9px;
|
|
color:#3a3a4a;
|
|
margin-top:2px;
|
|
}
|
|
.stat-row span:last-child { color:#666; }
|
|
button {
|
|
display:block;
|
|
width:100%;
|
|
background:#0e0e1c;
|
|
color:#666;
|
|
border:1px solid #222233;
|
|
padding:4px 7px;
|
|
font-family:'Courier New',monospace;
|
|
font-size:10px;
|
|
border-radius:2px;
|
|
cursor:pointer;
|
|
margin-bottom:3px;
|
|
text-align:left;
|
|
transition:all 0.1s;
|
|
}
|
|
button:hover { background:#161624; color:#aaa; border-color:#334; }
|
|
button:active { background:#1e1e30; }
|
|
.sp-item {
|
|
display:flex;
|
|
align-items:center;
|
|
gap:4px;
|
|
padding:3px;
|
|
border-radius:2px;
|
|
cursor:pointer;
|
|
border:1px solid transparent;
|
|
margin-bottom:2px;
|
|
}
|
|
.sp-item:hover { background:#0e0e1c; }
|
|
.sp-item.active { border-color:#223; background:#0e0e1c; }
|
|
.sp-dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
|
|
.sp-name { font-size:9px; flex:1; color:#888; }
|
|
.sp-pop { font-size:9px; color:#444; min-width:28px; text-align:right; }
|
|
.sp-gene { font-size:8px; color:#2a2a3a; }
|
|
.events-box {
|
|
height:66px;
|
|
overflow-y:auto;
|
|
font-size:8px;
|
|
line-height:1.7;
|
|
}
|
|
.ev { color:#2a2a3a; border-bottom:1px solid #0e0e18; padding:0; }
|
|
.ev-birth { color:#2a5a3a; }
|
|
.ev-extinct { color:#5a2a2a; }
|
|
.ev-mutate { color:#5a4a2a; }
|
|
input[type=range] { width:100%; margin:1px 0 5px; accent-color:#334; }
|
|
input[type=text] {
|
|
width:100%;
|
|
background:#06060e;
|
|
border:1px solid #1a1a28;
|
|
color:#666;
|
|
padding:3px 4px;
|
|
font-family:'Courier New',monospace;
|
|
font-size:9px;
|
|
border-radius:2px;
|
|
margin-bottom:3px;
|
|
}
|
|
input[type=text]:focus { outline:none; border-color:#334; color:#999; }
|
|
label { font-size:9px; color:#333; display:block; margin-bottom:1px; }
|
|
.gene-display {
|
|
background:#06060e;
|
|
border:1px solid #1a1a28;
|
|
border-radius:2px;
|
|
padding:3px 5px;
|
|
font-size:9px;
|
|
color:#444;
|
|
margin-top:3px;
|
|
word-break:break-all;
|
|
min-height:16px;
|
|
}
|
|
.copy-btn {
|
|
background:#0a0a16;
|
|
color:#445;
|
|
border:1px solid #1a1a28;
|
|
padding:2px 5px;
|
|
font-size:8px;
|
|
cursor:pointer;
|
|
border-radius:2px;
|
|
width:auto;
|
|
display:inline-block;
|
|
margin:2px 0 0;
|
|
}
|
|
.copy-btn:hover { color:#88a; border-color:#446; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🧬 物种进化模拟器</h1>
|
|
<div class="layout">
|
|
<div class="left">
|
|
<canvas id="game" width="720" height="480"></canvas>
|
|
<canvas id="graph" width="720" height="88"></canvas>
|
|
</div>
|
|
<div class="sidebar">
|
|
<!-- Stats -->
|
|
<div class="card">
|
|
<div class="big-num" id="genNum">0</div>
|
|
<div class="big-label">Generation</div>
|
|
<div class="stat-row"><span>活细胞</span><span id="aliveNum">0</span></div>
|
|
<div class="stat-row"><span>现存物种</span><span id="spAlive">0</span></div>
|
|
<div class="stat-row"><span>已灭绝</span><span id="spDead">0</span></div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="card">
|
|
<div class="card-title">控制</div>
|
|
<button id="btnPlay">▶ 开始</button>
|
|
<button id="btnStep">⏭ 单步</button>
|
|
<button id="btnReset">↺ 重置</button>
|
|
<button id="btnMeteor">☄ 陨石打击</button>
|
|
<label>速度 <span id="spdVal">10</span> fps</label>
|
|
<input type="range" id="spdSlider" min="1" max="30" value="10">
|
|
</div>
|
|
|
|
<!-- Species list / draw -->
|
|
<div class="card">
|
|
<div class="card-title">物种(点击切换画笔)</div>
|
|
<div id="spList"></div>
|
|
<button id="btnErase" style="margin-top:2px">✕ 橡皮擦</button>
|
|
<button id="btnAddRandom">+ 加入随机物种</button>
|
|
<div class="card-title" style="margin-top:5px">当前物种基因码</div>
|
|
<div class="gene-display" id="geneDisplay">—</div>
|
|
<button class="copy-btn" id="btnCopy">📋 复制基因码</button>
|
|
</div>
|
|
|
|
<!-- Import -->
|
|
<div class="card">
|
|
<div class="card-title">导入基因码</div>
|
|
<input type="text" id="geneInput" placeholder="色相-出生-存min-存max-变异率">
|
|
<button id="btnImport">↓ 导入物种</button>
|
|
</div>
|
|
|
|
<!-- Log -->
|
|
<div class="card">
|
|
<div class="card-title">进化日志</div>
|
|
<div class="events-box" id="evLog"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ===== CONSTANTS =====
|
|
const COLS = 120, ROWS = 80, CS = 6;
|
|
const MAX_SP = 14, HIST = 300;
|
|
|
|
// ===== STATE =====
|
|
let grid, sp, nextId, gen, running, raf, lastT, spd;
|
|
let drawSid = 1, mdown = false;
|
|
|
|
// ===== HELPERS =====
|
|
const clamp = (v,lo,hi) => Math.max(lo,Math.min(hi,v));
|
|
const rnd = (lo,hi) => lo + Math.floor(Math.random()*(hi-lo+1));
|
|
|
|
// ===== SPECIES =====
|
|
function addSp(opts={}) {
|
|
if (Object.keys(sp).length >= MAX_SP) return null;
|
|
const id = nextId++;
|
|
let birth = opts.birth ?? rnd(2,3);
|
|
let smin = opts.smin ?? rnd(1,2);
|
|
let smax = opts.smax ?? rnd(smin+1, smin+2);
|
|
let mut = opts.mut ?? (0.5 + Math.random()*2.5);
|
|
smin = clamp(smin, 0, 7);
|
|
smax = clamp(smax, smin+1, 8);
|
|
birth = clamp(birth, 1, 6);
|
|
mut = clamp(mut, 0.1, 15);
|
|
|
|
sp[id] = {
|
|
id,
|
|
name: opts.name ?? `物种${id}`,
|
|
hue: opts.hue ?? Math.random()*360,
|
|
birth, smin, smax, mut,
|
|
pop:0, hist:[], dead:false,
|
|
parent: opts.parent ?? null,
|
|
born: gen,
|
|
};
|
|
log(`🌱 ${sp[id].name} B${birth}/S${smin}-${smax} mut${mut.toFixed(1)}`, 'birth');
|
|
return id;
|
|
}
|
|
|
|
function geneCode(id) {
|
|
const s = sp[id];
|
|
if (!s) return '';
|
|
return `${Math.round(s.hue)}-${s.birth}-${s.smin}-${s.smax}-${Math.round(s.mut*10)}`;
|
|
}
|
|
|
|
function parseGene(str) {
|
|
const p = str.trim().split('-');
|
|
if (p.length < 5) return null;
|
|
const g = { hue:+p[0], birth:+p[1], smin:+p[2], smax:+p[3], mut:+p[4]/10 };
|
|
if ([g.hue,g.birth,g.smin,g.smax,g.mut].some(isNaN)) return null;
|
|
return g;
|
|
}
|
|
|
|
// ===== GRID =====
|
|
const mkGrid = () =>
|
|
Array.from({length:ROWS}, () =>
|
|
Array.from({length:COLS}, () => ({sid:0, age:0}))
|
|
);
|
|
|
|
function nbrs(x, y) {
|
|
const r = [];
|
|
for (let dy=-1; dy<=1; dy++)
|
|
for (let dx=-1; dx<=1; dx++) {
|
|
if (!dx && !dy) continue;
|
|
r.push(grid[(y+dy+ROWS)%ROWS][(x+dx+COLS)%COLS]);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// ===== CORE SIMULATION =====
|
|
// Birth rule: a dead cell is born as species S if it has exactly S.birth neighbors of species S
|
|
// Survival rule: a live cell of species S survives if it has S.smin..S.smax neighbors of same species
|
|
// Multi-species: if multiple species qualify for an empty cell, one is chosen randomly
|
|
// Mutation: on birth, small chance to produce a new sub-species (color drift + 1 gene ±1)
|
|
|
|
function tick(x, y) {
|
|
const cell = grid[y][x];
|
|
const ns = nbrs(x, y);
|
|
// Count neighbors per species
|
|
const cnt = {};
|
|
for (const n of ns) if (n.sid > 0) cnt[n.sid] = (cnt[n.sid]||0)+1;
|
|
|
|
if (cell.sid === 0) {
|
|
// --- BIRTH ---
|
|
const cands = [];
|
|
for (const [sid, c] of Object.entries(cnt)) {
|
|
const s = sp[+sid];
|
|
if (s && !s.dead && c === s.birth) cands.push(+sid);
|
|
}
|
|
if (!cands.length) return {sid:0, age:0};
|
|
const winner = cands[Math.floor(Math.random()*cands.length)];
|
|
return { sid: tryMutate(winner), age: 0 };
|
|
} else {
|
|
// --- SURVIVE ---
|
|
const s = sp[cell.sid];
|
|
if (!s) return {sid:0, age:0};
|
|
const same = cnt[cell.sid] || 0;
|
|
if (same >= s.smin && same <= s.smax)
|
|
return { sid: cell.sid, age: cell.age+1 };
|
|
return {sid:0, age:0};
|
|
}
|
|
}
|
|
|
|
function tryMutate(sid) {
|
|
const s = sp[sid];
|
|
if (!s || Math.random()*100 > s.mut) return sid;
|
|
if (Object.keys(sp).length >= MAX_SP) return sid;
|
|
|
|
const g = Math.floor(Math.random()*4);
|
|
const opts = {
|
|
name: s.name + "'",
|
|
hue: (s.hue + (Math.random()>0.5?22:-22)+360)%360,
|
|
birth: s.birth,
|
|
smin: s.smin,
|
|
smax: s.smax,
|
|
mut: clamp(s.mut*(0.6+Math.random()*0.8), 0.1, 15),
|
|
parent: sid,
|
|
};
|
|
if (g===0) opts.birth = clamp(s.birth + (Math.random()>0.5?1:-1), 1, 6);
|
|
if (g===1) opts.smin = clamp(s.smin + (Math.random()>0.5?1:-1), 0, s.smax-1);
|
|
if (g===2) opts.smax = clamp(s.smax + (Math.random()>0.5?1:-1), s.smin+1, 8);
|
|
// g===3: only mut changes
|
|
|
|
const nid = addSp(opts);
|
|
if (nid) log(`🔬 Gen${gen}: ${s.name} → ${sp[nid].name}`, 'mutate');
|
|
return nid ?? sid;
|
|
}
|
|
|
|
function step() {
|
|
const ng = Array.from({length:ROWS}, (_,y) =>
|
|
Array.from({length:COLS}, (_,x) => tick(x,y))
|
|
);
|
|
grid = ng;
|
|
gen++;
|
|
countPop();
|
|
checkExtinct();
|
|
}
|
|
|
|
function countPop() {
|
|
for (const id in sp) sp[id].pop = 0;
|
|
for (let y=0; y<ROWS; y++)
|
|
for (let x=0; x<COLS; x++) {
|
|
const sid = grid[y][x].sid;
|
|
if (sid && sp[sid]) sp[sid].pop++;
|
|
}
|
|
for (const id in sp) {
|
|
// Resurrection check
|
|
if (sp[id].dead && sp[id].pop > 0) {
|
|
sp[id].dead = false;
|
|
log(`✨ ${sp[id].name} 复活`, 'birth');
|
|
}
|
|
sp[id].hist.push(sp[id].pop);
|
|
if (sp[id].hist.length > HIST) sp[id].hist.shift();
|
|
}
|
|
}
|
|
|
|
function checkExtinct() {
|
|
for (const id in sp) {
|
|
const s = sp[id];
|
|
if (!s.dead && s.pop===0 && s.hist.length>8) {
|
|
s.dead = true;
|
|
log(`💀 Gen${gen}: ${s.name} 灭绝`, 'extinct');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== RENDER =====
|
|
const gc = document.getElementById('game');
|
|
const gx = gc.getContext('2d');
|
|
const pc = document.getElementById('graph');
|
|
const px = pc.getContext('2d');
|
|
|
|
function draw() {
|
|
gx.fillStyle = '#060610';
|
|
gx.fillRect(0, 0, gc.width, gc.height);
|
|
|
|
for (let y=0; y<ROWS; y++) {
|
|
for (let x=0; x<COLS; x++) {
|
|
const {sid, age} = grid[y][x];
|
|
if (!sid || !sp[sid]) continue;
|
|
const h = sp[sid].hue;
|
|
// Age: young cells bright, old cells deeper
|
|
const l = clamp(65 - age*0.35, 30, 65);
|
|
gx.fillStyle = `hsl(${h},72%,${l}%)`;
|
|
gx.fillRect(x*CS, y*CS, CS-1, CS-1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawGraph() {
|
|
const W = pc.width, H = pc.height;
|
|
px.fillStyle = '#050510';
|
|
px.fillRect(0, 0, W, H);
|
|
|
|
// Subtle grid
|
|
px.strokeStyle = '#0e0e1e'; px.lineWidth=1;
|
|
for (let i=1; i<4; i++) {
|
|
px.beginPath(); px.moveTo(0,H*i/4); px.lineTo(W,H*i/4); px.stroke();
|
|
}
|
|
|
|
// Find max for scale
|
|
let mx = 80;
|
|
for (const id in sp)
|
|
for (const v of sp[id].hist) if (v>mx) mx=v;
|
|
|
|
// Draw per species
|
|
for (const id in sp) {
|
|
const s = sp[id];
|
|
const h = s.hist;
|
|
if (h.length < 2) continue;
|
|
const alive = !s.dead;
|
|
px.strokeStyle = `hsl(${s.hue},70%,${alive?48:20}%)`;
|
|
px.lineWidth = alive ? 1.5 : 0.5;
|
|
px.beginPath();
|
|
for (let i=0; i<h.length; i++) {
|
|
const bx = ((HIST - h.length + i) / HIST) * W;
|
|
const by = H - (h[i]/mx)*H*0.9 - 2;
|
|
i===0 ? px.moveTo(bx,by) : px.lineTo(bx,by);
|
|
}
|
|
px.stroke();
|
|
}
|
|
|
|
// Gen label
|
|
px.fillStyle = '#222';
|
|
px.font = '9px monospace';
|
|
px.fillText(`Gen ${gen}`, 4, H-3);
|
|
}
|
|
|
|
function updateUI() {
|
|
document.getElementById('genNum').textContent = gen;
|
|
|
|
let totalCells=0, alive=0, dead=0;
|
|
for (const id in sp) {
|
|
totalCells += sp[id].pop;
|
|
if (sp[id].dead) dead++;
|
|
else if (sp[id].pop > 0) alive++;
|
|
}
|
|
document.getElementById('aliveNum').textContent = totalCells.toLocaleString();
|
|
document.getElementById('spAlive').textContent = alive;
|
|
document.getElementById('spDead').textContent = dead;
|
|
|
|
// Gene display
|
|
const gd = document.getElementById('geneDisplay');
|
|
if (drawSid > 0 && sp[drawSid]) gd.textContent = geneCode(drawSid);
|
|
else gd.textContent = '—';
|
|
|
|
// Species list
|
|
const list = document.getElementById('spList');
|
|
list.innerHTML = '';
|
|
const sorted = Object.values(sp).sort((a,b) => b.pop - a.pop);
|
|
for (const s of sorted) {
|
|
const div = document.createElement('div');
|
|
div.className = 'sp-item' + (drawSid===s.id?' active':'');
|
|
div.style.opacity = s.dead ? '0.3' : '1';
|
|
div.innerHTML = `
|
|
<div class="sp-dot" style="background:hsl(${s.hue},72%,50%)"></div>
|
|
<div style="flex:1">
|
|
<div class="sp-name">${s.name}</div>
|
|
<div class="sp-gene">B${s.birth}/S${s.smin}-${s.smax} m${s.mut.toFixed(1)}</div>
|
|
</div>
|
|
<div class="sp-pop">${s.pop}</div>
|
|
`;
|
|
div.addEventListener('click', () => { drawSid = s.id; updateUI(); });
|
|
list.appendChild(div);
|
|
}
|
|
}
|
|
|
|
// ===== GAME LOOP =====
|
|
function loop(t) {
|
|
if (!running) return;
|
|
if (t - lastT >= 1000/spd) {
|
|
step(); draw(); drawGraph(); updateUI();
|
|
lastT = t;
|
|
}
|
|
raf = requestAnimationFrame(loop);
|
|
}
|
|
|
|
// ===== INIT =====
|
|
function init() {
|
|
running = false;
|
|
if (raf) cancelAnimationFrame(raf);
|
|
document.getElementById('btnPlay').textContent = '▶ 开始';
|
|
document.getElementById('evLog').innerHTML = '';
|
|
|
|
grid = mkGrid();
|
|
sp = {};
|
|
nextId = 1;
|
|
gen = 0;
|
|
|
|
// 4 preset species with distinct personalities
|
|
addSp({ name:'红霸', hue:0, birth:3, smin:2, smax:3, mut:1.0 }); // Conway classic
|
|
addSp({ name:'蓝潮', hue:210, birth:3, smin:2, smax:4, mut:1.5 }); // More tolerant
|
|
addSp({ name:'绿芽', hue:130, birth:2, smin:1, smax:3, mut:2.5 }); // Aggressive spreader
|
|
addSp({ name:'金堡', hue:45, birth:3, smin:3, smax:5, mut:0.4 }); // Cluster fortress
|
|
|
|
// Place each in a quadrant
|
|
const seeds = [
|
|
{sid:1, cx:20, cy:20}, {sid:2, cx:100, cy:20},
|
|
{sid:3, cx:20, cy:60}, {sid:4, cx:100, cy:60},
|
|
];
|
|
for (const {sid, cx, cy} of seeds)
|
|
for (let i=0; i<45; i++) {
|
|
const x = clamp(cx + rnd(-10,10), 0, COLS-1);
|
|
const y = clamp(cy + rnd(-8,8), 0, ROWS-1);
|
|
grid[y][x] = { sid, age:0 };
|
|
}
|
|
|
|
drawSid = 1;
|
|
draw(); drawGraph(); updateUI();
|
|
}
|
|
|
|
// ===== MOUSE =====
|
|
function gridPos(e) {
|
|
const r = gc.getBoundingClientRect();
|
|
return {
|
|
x: clamp(Math.floor((e.clientX-r.left)*(COLS/r.width)), 0, COLS-1),
|
|
y: clamp(Math.floor((e.clientY-r.top) *(ROWS/r.height)), 0, ROWS-1),
|
|
};
|
|
}
|
|
|
|
function paint(x, y) {
|
|
const br = 2;
|
|
for (let dy=-br; dy<=br; dy++)
|
|
for (let dx=-br; dx<=br; dx++) {
|
|
if (dx*dx+dy*dy > br*br+1) continue;
|
|
const nx=clamp(x+dx,0,COLS-1), ny=clamp(y+dy,0,ROWS-1);
|
|
grid[ny][nx] = drawSid===0 ? {sid:0,age:0} : {sid:drawSid,age:0};
|
|
}
|
|
if (!running) { draw(); updateUI(); }
|
|
}
|
|
|
|
gc.addEventListener('mousedown', e => { mdown=true; const p=gridPos(e); paint(p.x,p.y); });
|
|
gc.addEventListener('mousemove', e => { if(mdown){ const p=gridPos(e); paint(p.x,p.y); }});
|
|
gc.addEventListener('mouseup', () => mdown=false);
|
|
gc.addEventListener('mouseleave', () => mdown=false);
|
|
gc.addEventListener('contextmenu',e => e.preventDefault());
|
|
|
|
// ===== BUTTONS =====
|
|
document.getElementById('btnPlay').addEventListener('click', () => {
|
|
running = !running;
|
|
document.getElementById('btnPlay').textContent = running ? '⏸ 暂停' : '▶ 开始';
|
|
if (running) { lastT=0; raf=requestAnimationFrame(loop); }
|
|
});
|
|
|
|
document.getElementById('btnStep').addEventListener('click', () => {
|
|
if (running) return;
|
|
step(); draw(); drawGraph(); updateUI();
|
|
});
|
|
|
|
document.getElementById('btnReset').addEventListener('click', init);
|
|
|
|
document.getElementById('btnMeteor').addEventListener('click', () => {
|
|
const cx=rnd(12,COLS-12), cy=rnd(8,ROWS-8), r=rnd(7,13);
|
|
for (let dy=-r; dy<=r; dy++)
|
|
for (let dx=-r; dx<=r; dx++)
|
|
if (dx*dx+dy*dy<=r*r)
|
|
grid[clamp(cy+dy,0,ROWS-1)][clamp(cx+dx,0,COLS-1)] = {sid:0,age:0};
|
|
log(`☄ Gen${gen}: 陨石 [${cx},${cy}] r=${r}`, 'extinct');
|
|
if (!running) { draw(); updateUI(); }
|
|
});
|
|
|
|
document.getElementById('spdSlider').addEventListener('input', e => {
|
|
spd = +e.target.value;
|
|
document.getElementById('spdVal').textContent = spd;
|
|
});
|
|
|
|
document.getElementById('btnErase').addEventListener('click', () => { drawSid=0; updateUI(); });
|
|
|
|
document.getElementById('btnAddRandom').addEventListener('click', () => {
|
|
const id = addSp();
|
|
if (id) { drawSid=id; updateUI(); }
|
|
});
|
|
|
|
document.getElementById('btnCopy').addEventListener('click', () => {
|
|
if (drawSid > 0 && sp[drawSid]) {
|
|
const code = geneCode(drawSid);
|
|
navigator.clipboard.writeText(code).then(() => {
|
|
const btn = document.getElementById('btnCopy');
|
|
btn.textContent = '✓ 已复制';
|
|
setTimeout(() => btn.textContent = '📋 复制基因码', 1200);
|
|
});
|
|
}
|
|
});
|
|
|
|
document.getElementById('btnImport').addEventListener('click', () => {
|
|
const inp = document.getElementById('geneInput');
|
|
const g = parseGene(inp.value);
|
|
if (!g) { alert('格式错误\n正确格式: 色相-出生-存min-存max-变异率\n例如: 270-3-2-3-15'); return; }
|
|
const id = addSp(g);
|
|
if (id) { drawSid=id; inp.value=''; updateUI(); }
|
|
});
|
|
|
|
// ===== LOG =====
|
|
function log(msg, type='') {
|
|
const box = document.getElementById('evLog');
|
|
const d = document.createElement('div');
|
|
d.className = `ev ev-${type}`;
|
|
d.textContent = msg;
|
|
box.insertBefore(d, box.firstChild);
|
|
while(box.children.length > 40) box.removeChild(box.lastChild);
|
|
}
|
|
|
|
// ===== START =====
|
|
spd = 10;
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|