영국의 수학자 존 호른 콘웨이가 고안해낸 세포자동자 의 일종이다.
게임은 처음 입력된 초기값에 따라 진행이 완전히 결정되며, 각 세포 주위에 인접해있는 8개의 이웃 세포에 따라 다음 세대의 세포 상태를 결정하게 된다.
러스트를 사용하여 코드를 구현 후 이를 웹어셈블리로 컴파일하여 웹 브라우저에서 실행할 수 있도록 한다.
wasm-bindgen과 wasm-pack을 사용하였다.
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
Dead = 0,
Alive = 1,
}
#[wasm_bindgen]
pub struct Universe {
width: u32,
height: u32,
cells: Vec<Cell>,
}
Universe
구조체를 구현한다.먼저 게임 셀의 getter/setter
함수를 구현한다.
#[wasm_bindgen]
impl Universe {
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn cells(&self) -> *const Cell {
self.cells.as_ptr()
}
fn get_index(&self, row: u32, column: u32) -> usize {
(row * self.width + column) as usize
}
pub fn set_cell(&mut self, row: u32, column: u32, cell: Cell) {
let idx = self.get_index(row, column);
self.cells[idx] = cell;
}
// ...
}
그 다음 이웃 세포의 개수를 세는 함수를 구현한다.
#[wasm_bindgen]
impl Universe {
// ...
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
let mut count = 0;
for delta_row in [self.height - 1, 0, 1].iter().cloned() {
for delta_col in [self.width - 1, 0, 1].iter().cloned() {
if delta_row == 0 && delta_col == 0 {
continue;
}
let neighbor_row = (row + delta_row) % self.height;
let neighbor_col = (column + delta_col) % self.width;
let idx = self.get_index(neighbor_row, neighbor_col);
count += self.cells[idx] as u8;
}
}
count
}
// ...
}
이제 매 세대마다 세포의 상태를 업데이트하는 함수를 구현한다. 앞서 언급한 규칙을 적용하여 다음 세대의 세포 상태를 결정한다.
#[wasm_bindgen]
impl Universe {
// ...
pub fn tick(&mut self) {
let mut next = self.cells.clone();
for row in 0..self.height {
for col in 0..self.width {
let idx = self.get_index(row, col);
let cell = self.cells[idx];
let live_neighbors = self.live_neighbor_count(row, col);
let next_cell = match (cell, live_neighbors) {
// 규칙 1: 살아있는 세포의 이웃 중 2개 미만이면 죽는다.
(Cell::Alive, x) if x < 2 => Cell::Dead,
// 규칙 2: 살아있는 세포의 이웃 중 2개 또는 3개가 살아있으면 다음 세대에도 살아있게 된다.
(Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
// 규칙 3: 살아있는 세포의 이웃 중 3개 초과이면 죽는다.
(Cell::Alive, x) if x > 3 => Cell::Dead,
// 규칙 4: 죽은 세포의 이웃 중 정확히 3개가 살아있으면 다음 세대에 살아난다.
(Cell::Dead, 3) => Cell::Alive,
// 이외의 경우, 그 세포는 다음 세대에 죽게 된다.
(otherwise, _) => otherwise,
};
next[idx] = next_cell;
}
}
self.cells = next;
}
// ...
}
마지막으로 게임 초기값을 설정하는 함수를 구현한다.
#[wasm_bindgen]
impl Universe {
// ...
pub fn new(width: u32, height: u32) -> Universe {
let cells = (0..width * height)
.map(|i| Cell::Dead)
.collect();
Universe {
width,
height,
cells,
}
}
}
먼저 프로젝트 루트 디렉토리에서 다음 명령어를 실행하여 웹 어셈블리로 컴파일한다.
wasm-pack build .
컴파일 후 생성된 파일은 pkg
디렉토리에 생성된다.
Next.js 환경에서 웹어셈블리를 사용하기 위해서는 클라이언트 사이드에서만 실행되도록 해야 한다. dynamic
import를 사용하여 SSR을 비활성화한다.
import dynamic from "next/dynamic";
const WasmConwaysGame = dynamic(() => Promise.resolve(WasmConwaysGameComponent), {
ssr: false,
});
웹어셈블리의 가장 중요한 특징 중 하나는 메모리 공유이다. 러스트에서 생성된 데이터는 웹어셈블리의 선형 메모리에 저장되며, 이를 자바스크립트에서 직접 접근할 수 있다.
const [memory, setMemory] = useState<any | null>(null);
// 웹어셈블리 모듈과 메모리 로딩
const loadWasm = async () => {
const wasmModule = await import("@/examples/conway-wasm/pkg/conway_wasm");
const wasmMemoryModule = await import("@/examples/conway-wasm/pkg/conway_wasm_bg.wasm");
setMemory(wasmMemoryModule.memory);
// ...
};
웹어셈블리의 선형 메모리에서 데이터를 직접 읽어오는 것이 핵심이다.
// 러스트에서 셀 데이터의 포인터 가져오기
const cellsPtr = universe.cells();
// 메모리에서 직접 데이터 읽기 (포인터 기반)
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);
이 방식의 장점:
러스트의 Vec<Cell>
은 웹어셈블리 메모리에서 다음과 같이 배치된다:
// 러스트 코드
pub struct Universe {
width: u32, // 4바이트
height: u32, // 4바이트
cells: Vec<Cell> // 포인터 + 길이 (8바이트 + 8바이트)
}
자바스크립트에서는 이 구조를 다음과 같이 해석한다:
// 메모리에서 직접 구조체 데이터 읽기
const cellsPtr = universe.cells(); // Vec의 포인터
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);
메모리 공유를 통해 실시간으로 게임 상태를 렌더링할 수 있다:
useEffect(() => {
if (!universe || !canvasRef.current || !Cell || !memory) return;
const width = universe.width();
const height = universe.height();
const cellsPtr = universe.cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);
// 캔버스에 직접 렌더링
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const idx = row * width + col;
ctx.fillStyle = cells[idx] === Cell.Alive ? ALIVE_COLOR : DEAD_COLOR;
ctx.fillRect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE);
}
}
}, [universe, generation, Cell, memory]);
웹어셈블리 메모리를 직접 조작할 때는 다음 사항을 주의해야 한다:
// 안전한 메모리 접근 예시
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!universe || !canvasRef.current || !Cell || !memory) return;
// 좌표 검증
const x = Math.floor((e.clientX - rect.left) * scaleX / CELL_SIZE);
const y = Math.floor((e.clientY - rect.top) * scaleY / CELL_SIZE);
if (x < 0 || y < 0 || x >= universe.width() || y >= universe.height()) return;
// 메모리에서 안전하게 데이터 읽기
const cellsPtr = universe.cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, universe.width() * universe.height());
const idx = y * universe.width() + x;
// 상태 변경
const current = cells[idx];
universe.set_cell(y, x, current === Cell.Alive ? Cell.Dead : Cell.Alive);
};
'use client';
import dynamic from "next/dynamic";
import { useEffect, useRef, useState } from "react";
const CELL_SIZE = 10;
const GRID_COLOR = "#3f3f46";
const DEAD_COLOR = "#09090b";
const ALIVE_COLOR = "#a1a1aa";
// 특수 패턴들 정의
const PATTERNS = {
glider: {
name: "글라이더",
cells: [
[0, 1],
[1, 2],
[2, 0],
[2, 1],
[2, 2]
]
},
spaceshipSmall: {
name: "소형 우주선",
cells: [
[0, 1],
[0, 4],
[1, 0],
[2, 0],
[2, 4],
[3, 0],
[3, 1],
[3, 2],
[3, 3]
]
},
gun: {
name: "글라이더 총",
cells: [
[0, 24],
[1, 22],
[1, 24],
[2, 12],
[2, 13],
[2, 20],
[2, 21],
[2, 34],
[2, 35],
[3, 11],
[3, 15],
[3, 20],
[3, 21],
[3, 34],
[3, 35],
[4, 0],
[4, 1],
[4, 10],
[4, 16],
[4, 20],
[4, 21],
[5, 0],
[5, 1],
[5, 10],
[5, 14],
[5, 16],
[5, 17],
[5, 22],
[5, 24],
[6, 10],
[6, 16],
[6, 24],
[7, 11],
[7, 15],
[8, 12],
[8, 13]
]
},
locomotive: {
name: "기관차",
cells: [
[0, 2],
[0, 3],
[1, 0],
[1, 1],
[1, 3],
[1, 4],
[2, 0],
[2, 1],
[2, 2],
[2, 3]
]
},
beacon: {
name: "비콘",
cells: [
[0, 0],
[0, 1],
[1, 0],
[2, 3],
[3, 2],
[3, 3]
]
},
toad: {
name: "두꺼비",
cells: [
[1, 1],
[1, 2],
[1, 3],
[2, 0],
[2, 1],
[2, 2]
]
},
acorn: {
name: "도토리",
cells: [
[0, 1],
[1, 3],
[2, 0],
[2, 1],
[2, 4],
[2, 5],
[2, 6]
]
},
diehard: {
name: "다이하드",
cells: [
[0, 6],
[1, 0],
[1, 1],
[2, 1],
[2, 5],
[2, 6],
[2, 7]
]
}
};
function WasmConwaysGameComponent() {
const [universe, setUniverse] = useState<any | null>(null);
const [Cell, setCell] = useState<any | null>(null);
const [memory, setMemory] = useState<any | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [generation, setGeneration] = useState(0);
const animationRef = useRef<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [dimensions, setDimensions] = useState({ width: 84, height: 54 });
const [speed, setSpeed] = useState(60);
const tickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [renderTrigger, setRenderTrigger] = useState(0);
useEffect(() => {
const loadWasm = async () => {
try {
setIsLoading(true);
setError(null);
const wasmModule = await import("@/examples/conway-wasm/pkg/conway_wasm");
const wasmMemoryModule = await import("@/examples/conway-wasm/pkg/conway_wasm_bg.wasm");
setCell(wasmModule.Cell);
setMemory(wasmMemoryModule.memory);
const uni = await wasmModule.Universe.new(dimensions.width, dimensions.height);
setUniverse(uni);
setGeneration(0);
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
loadWasm();
}, [dimensions.width, dimensions.height]);
// 캔버스 렌더링
useEffect(() => {
if (!universe || !canvasRef.current || !Cell || !memory) return;
const width = universe.width();
const height = universe.height();
const cellsPtr = universe.cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// 배경
ctx.fillStyle = DEAD_COLOR;
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// 셀 먼저 그리기
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const idx = row * width + col;
ctx.fillStyle = cells[idx] === Cell.Alive ? ALIVE_COLOR : DEAD_COLOR;
ctx.fillRect(
col * CELL_SIZE,
row * CELL_SIZE,
CELL_SIZE,
CELL_SIZE
);
}
}
// 그리드 선
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 0.2;
// 수평선
for (let y = 0; y <= height; y++) {
const yPos = y * CELL_SIZE;
ctx.beginPath();
ctx.moveTo(0, yPos);
ctx.lineTo(width * CELL_SIZE, yPos);
ctx.stroke();
}
// 수직선
for (let x = 0; x <= width; x++) {
const xPos = x * CELL_SIZE;
ctx.beginPath();
ctx.moveTo(xPos, 0);
ctx.lineTo(xPos, height * CELL_SIZE);
ctx.stroke();
}
}, [universe, generation, Cell, memory, renderTrigger]);
// 애니메이션 루프
useEffect(() => {
if (!isRunning) {
if (tickTimeoutRef.current) {
clearTimeout(tickTimeoutRef.current);
tickTimeoutRef.current = null;
}
return;
}
const step = () => {
if (universe) {
universe.tick();
setGeneration((g) => g + 1);
}
tickTimeoutRef.current = setTimeout(step, speed);
};
tickTimeoutRef.current = setTimeout(step, speed);
return () => {
if (tickTimeoutRef.current) {
clearTimeout(tickTimeoutRef.current);
tickTimeoutRef.current = null;
}
};
}, [isRunning, universe, speed]);
// 셀 클릭 토글
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!universe || !canvasRef.current || !Cell || !memory) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX / CELL_SIZE);
const y = Math.floor((e.clientY - rect.top) * scaleY / CELL_SIZE);
if (x < 0 || y < 0 || x >= universe.width() || y >= universe.height()) return;
const cellsPtr = universe.cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, universe.width() * universe.height());
const idx = y * universe.width() + x;
const current = cells[idx];
universe.set_cell(y, x, current === Cell.Alive ? Cell.Dead : Cell.Alive);
setGeneration((g) => g + 1);
};
// 컨트롤 버튼
const handleStart = () => setIsRunning(true);
const handlePause = () => setIsRunning(false);
const handleReset = async () => {
setIsRunning(false);
setIsLoading(true);
setError(null);
try {
const wasmModule = await import("@/examples/conway-wasm/pkg/conway_wasm");
const uni = await wasmModule.Universe.new(dimensions.width, dimensions.height);
setUniverse(uni);
setGeneration(0);
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
// 다음 틱
const handleNextTick = () => {
if (universe) {
universe.tick();
setGeneration((g) => g + 1);
}
};
// 패턴 삽입 함수
const insertPattern = (patternKey: keyof typeof PATTERNS) => {
if (!universe || !Cell) return;
const pattern = PATTERNS[patternKey];
const width = universe.width();
const height = universe.height();
// 먼저 모든 셀을 죽음 상태로 초기화
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
universe.set_cell(row, col, Cell.Dead);
}
}
// 중앙에 패턴 배치
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
// 패턴 크기 계산
const maxRow = Math.max(...pattern.cells.map(cell => cell[0]));
const maxCol = Math.max(...pattern.cells.map(cell => cell[1]));
const minRow = Math.min(...pattern.cells.map(cell => cell[0]));
const minCol = Math.min(...pattern.cells.map(cell => cell[1]));
const patternWidth = maxCol - minCol + 1;
const patternHeight = maxRow - minRow + 1;
// 패턴 시작 위치
const startX = centerX - Math.floor(patternWidth / 2);
const startY = centerY - Math.floor(patternHeight / 2);
// 패턴 셀들을 유니버스에 설정
pattern.cells.forEach(([row, col]) => {
const x = startX + col - minCol;
const y = startY + row - minRow;
// 경계 체크
if (x >= 0 && x < width && y >= 0 && y < height) {
universe.set_cell(y, x, Cell.Alive);
}
});
setGeneration(0);
setRenderTrigger(prev => prev + 1);
};
return (
<div className="bg-zinc-950 text-zinc-100 p-4">
<div className="max-w-4xl mx-auto">
{/* 컨트롤 패널 */}
<div className="bg-zinc-900/50 rounded-lg p-4 mb-4 border border-zinc-800/50">
<div className="flex flex-wrap gap-2 justify-between items-center">
<div className="flex gap-2">
<button
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isRunning
? "bg-zinc-600 hover:bg-zinc-500 text-white"
: "bg-zinc-700 hover:bg-zinc-600 text-zinc-100"
} disabled:opacity-50 disabled:cursor-not-allowed`}
onClick={isRunning ? handlePause : handleStart}
disabled={isLoading || !!error}
>
{isRunning ? "일시정지" : "시작"}
</button>
<button
className="px-3 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleReset}
disabled={isLoading}
>
초기화
</button>
<button
className="px-3 py-2 rounded-lg bg-zinc-600 hover:bg-zinc-500 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleNextTick}
disabled={isRunning || isLoading}
>
다음 세대
</button>
</div>
{/* 속도 컨트롤 */}
<div className="flex items-center justify-center gap-3">
<span className="text-zinc-400 text-sm">속도</span>
<div className="flex items-center gap-2 bg-zinc-800/30 px-3 py-1 rounded">
<span className="text-zinc-500 text-xs">빠름</span>
<input
type="range"
min={20}
max={500}
step={10}
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
className="w-24 h-1 bg-zinc-700 rounded appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #a1a1aa 0%, #a1a1aa ${
((speed - 20) / 480) * 100
}%, #52525b ${
((speed - 20) / 480) * 100
}%, #52525b 100%)`,
}}
/>
<span className="text-zinc-500 text-xs">느림</span>
</div>
<div className="text-zinc-300 text-xs font-mono bg-zinc-800/30 px-2 py-1 rounded">
{(1000 / speed).toFixed(1)} gen/s
</div>
</div>
{/* 세대 정보 */}
<div className="flex items-center gap-2 bg-zinc-800/50 px-3 py-1 rounded border border-zinc-700/50">
<span className="text-zinc-400 text-sm">세대:</span>
<span className="text-zinc-100 font-mono font-semibold">
{generation.toLocaleString()}
</span>
</div>
</div>
</div>
{/* 패턴 툴바 */}
<div className="bg-zinc-900/30 rounded-lg p-3 mb-4 border border-zinc-800/30">
<div className="flex flex-wrap gap-2 items-center">
<span className="text-zinc-400 text-sm font-medium mr-2">패턴:</span>
{Object.entries(PATTERNS).map(([key, pattern]) => (
<button
key={key}
className="px-3 py-1.5 rounded-md bg-zinc-800/50 hover:bg-zinc-700/70 text-zinc-200 text-xs font-medium transition-colors border border-zinc-700/30 hover:border-zinc-600/50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => insertPattern(key as keyof typeof PATTERNS)}
disabled={isLoading || !!error}
title={`${pattern.name} 패턴을 중앙에 삽입합니다`}
>
{pattern.name}
</button>
))}
<div className="ml-auto text-zinc-500 text-xs">
클릭하여 패턴을 중앙에 삽입
</div>
</div>
</div>
{/* 게임 보드 */}
<div className="flex justify-center">
<div className="relative">
<div
className="rounded-lg border border-zinc-800/50 overflow-hidden bg-zinc-950"
style={{
background: `linear-gradient(135deg, ${DEAD_COLOR} 0%, #18181b 100%)`,
}}
>
<canvas
ref={canvasRef}
width={dimensions.width * CELL_SIZE}
height={dimensions.height * CELL_SIZE}
onClick={handleCanvasClick}
className="block cursor-crosshair select-none"
style={{
imageRendering: "pixelated",
background: DEAD_COLOR,
maxWidth: "100%",
height: "auto",
}}
/>
</div>
{/* 인터랙션 힌트 */}
{!isRunning && !isLoading && (
<div className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 text-center">
<p className="text-zinc-500 text-xs">
클릭하여 셀을 토글할 수 있습니다
</p>
</div>
)}
</div>
</div>
{/* 로딩 및 에러 상태 */}
{isLoading && (
<div className="text-center mt-4">
<div className="inline-flex items-center gap-2 bg-zinc-800/50 px-4 py-2 rounded">
<div className="w-4 h-4 border-2 border-zinc-600 border-t-zinc-400 rounded-full animate-spin"></div>
<span className="text-zinc-300 text-sm">
WebAssembly 모듈을 로딩 중...
</span>
</div>
</div>
)}
{error && (
<div className="text-center mt-4">
<div className="inline-flex items-center gap-2 bg-red-950/50 px-4 py-2 rounded border border-red-900/50">
<span className="text-red-400 text-sm">{error}</span>
</div>
</div>
)}
</div>
<style jsx>{`
.slider::-webkit-slider-thumb {
appearance: none;
height: 12px;
width: 12px;
border-radius: 50%;
background: #a1a1aa;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #d4d4d8;
transform: scale(1.1);
}
.slider::-moz-range-thumb {
height: 12px;
width: 12px;
border-radius: 50%;
background: #a1a1aa;
cursor: pointer;
border: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
`}</style>
</div>
);
}
const WasmConwaysGame = dynamic(() => Promise.resolve(WasmConwaysGameComponent), {
ssr: false,
});
export default WasmConwaysGame;