돌아가기
#wasm
#rust
#nextjs
#conway's life game

웹어셈블리로 콘웨이의 생명 게임 만들기 (with Rust)

콘웨이의 생명 게임이란

영국의 수학자 존 호른 콘웨이가 고안해낸 세포자동자 의 일종이다.

게임은 처음 입력된 초기값에 따라 진행이 완전히 결정되며, 각 세포 주위에 인접해있는 8개의 이웃 세포에 따라 다음 세대의 세포 상태를 결정하게 된다.

게임의 규칙

  1. 죽은 세포의 이웃 중 정확히 3개가 살아있으면 그 세포는 다음 세대에 살아난다.
  2. 살아있는 세포의 이웃 중 2개 또는 3개가 살아있으면 그 세포는 다음 세대에도 살아있게 된다.
  3. 이외의 경우, 그 세포는 다음 세대에 죽게 된다.

웹 어셈블리로 구현하기

러스트를 사용하여 코드를 구현 후 이를 웹어셈블리로 컴파일하여 웹 브라우저에서 실행할 수 있도록 한다.

wasm-bindgenwasm-pack을 사용하였다.

코드 작성

  1. 먼저 게임의 셀과 게임 화면을 정의한다.
rust
#[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>,
}
  1. Universe 구조체를 구현한다.

먼저 게임 셀의 getter/setter 함수를 구현한다.

rust
#[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;
    }
 
    // ...
}

그 다음 이웃 세포의 개수를 세는 함수를 구현한다.

rust
#[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
    }
    // ...
}

이제 매 세대마다 세포의 상태를 업데이트하는 함수를 구현한다. 앞서 언급한 규칙을 적용하여 다음 세대의 세포 상태를 결정한다.

rust
#[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;
    }
    // ...
}

마지막으로 게임 초기값을 설정하는 함수를 구현한다.

rust
#[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,
        }
    }
}

웹 어셈블리로 컴파일하기

먼저 프로젝트 루트 디렉토리에서 다음 명령어를 실행하여 웹 어셈블리로 컴파일한다.

bash
wasm-pack build .
Terminal
[INFO]: Installing wasm-bindgen... [INFO]: Optimizing wasm binaries with wasm-opt... [INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended [INFO]: :-) Done in 11.49s [INFO]: :-) Your wasm pkg is ready to publish at pkg.

컴파일 후 생성된 파일은 pkg 디렉토리에 생성된다.

conway-wasm/
pkg/
conway_wasm_bg.js
conway_wasm_bg.wasm
conway_wasm_bg.wasm.d.ts
conway_wasm.d.ts
conway_wasm.js
package.json
src/
lib.rs
Cargo.toml
Cargo.lock

Next.js에서 웹어셈블리 사용하기

웹어셈블리 모듈 로딩

Next.js 환경에서 웹어셈블리를 사용하기 위해서는 클라이언트 사이드에서만 실행되도록 해야 한다. dynamic import를 사용하여 SSR을 비활성화한다.

typescript
import dynamic from "next/dynamic";
 
const WasmConwaysGame = dynamic(() => Promise.resolve(WasmConwaysGameComponent), {
    ssr: false,
});

웹어셈블리 메모리 관리

웹어셈블리의 가장 중요한 특징 중 하나는 메모리 공유이다. 러스트에서 생성된 데이터는 웹어셈블리의 선형 메모리에 저장되며, 이를 자바스크립트에서 직접 접근할 수 있다.

1. 메모리 객체 가져오기

typescript
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);
    // ...
};

2. 메모리에서 데이터 직접 읽기

웹어셈블리의 선형 메모리에서 데이터를 직접 읽어오는 것이 핵심이다.

typescript
// 러스트에서 셀 데이터의 포인터 가져오기
const cellsPtr = universe.cells();
 
// 메모리에서 직접 데이터 읽기 (포인터 기반)
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

이 방식의 장점:

  • 성능: 자바스크립트와 웹어셈블리 간 데이터 복사 없음
  • 메모리 효율성: 동일한 메모리 공간을 공유
  • 실시간 동기화: 웹어셈블리에서 데이터가 변경되면 자바스크립트에서 즉시 반영

3. 메모리 레이아웃 이해

러스트의 Vec<Cell>은 웹어셈블리 메모리에서 다음과 같이 배치된다:

rust
// 러스트 코드
pub struct Universe {
    width: u32,      // 4바이트
    height: u32,     // 4바이트  
    cells: Vec<Cell> // 포인터 + 길이 (8바이트 + 8바이트)
}

자바스크립트에서는 이 구조를 다음과 같이 해석한다:

typescript
// 메모리에서 직접 구조체 데이터 읽기
const cellsPtr = universe.cells(); // Vec의 포인터
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

4. 실시간 렌더링

메모리 공유를 통해 실시간으로 게임 상태를 렌더링할 수 있다:

typescript
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]);

5. 메모리 안전성 주의사항

웹어셈블리 메모리를 직접 조작할 때는 다음 사항을 주의해야 한다:

  • 포인터 유효성: 메모리 해제 후 포인터 사용 금지
  • 경계 검사: 배열 범위를 벗어나는 접근 방지
  • 동기화: 메모리 변경 시 적절한 상태 업데이트
typescript
// 안전한 메모리 접근 예시
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);
};

완성본

UI 컴포넌트 코드

tsx
'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;