Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

데스크톱 프런트엔드 작성하기

이 절에서는 마침내 모든 조각을 연결하여 에뮬레이터가 게임을 불러와 실행하도록 만들겠습니다. 현재 단계에서 에뮬레이터 코어는 게임의 옵코드를 해석하고 처리할 수 있으며, 필요에 따라 화면, RAM, 레지스터를 갱신할 수 있습니다. 다음으로, 게임 불러오기, 사용자 입력 받기, 표시할 화면 버퍼 공유하기 등의 기능을 프런트엔드에 노출하기 위해 몇 가지 공개 함수를 추가해야 합니다.

코어를 프런트엔드에 노출하기

chip8_core는 아직 끝나지 않았습니다. Emu 구조체의 일부 항목에 접근할 수 있도록 몇 가지 공개 함수를 추가해야 합니다.

chip8_core/src/lib.rs에서 Emu 구조체에 다음 메서드를 추가하십시오.

#![allow(unused)]
fn main() {
impl Emu {
    // -- Unchanged code omitted --

    pub fn get_display(&self) -> &[bool] {
        &self.screen
    }

    // -- Unchanged code omitted --
}
}

이 메서드는 단순히 화면 버퍼 배열에 대한 포인터를 프런트엔드로 전달하며, 프런트엔드에서는 이를 사용하여 디스플레이에 렌더링할 수 있습니다.

다음으로, 키 입력을 처리해야 합니다. 이미 keys 배열이 있지만, 실제로 여기에 값이 기록되는 일은 없습니다. 실제로 키보드 입력을 읽는 일은 프런트엔드가 담당하지만, 프런트엔드가 keys 배열과 상호작용하여 원소를 설정할 수 있게 해 주는 함수를 노출해야 합니다.

#![allow(unused)]
fn main() {
impl Emu {
    // -- Unchanged code omitted --

    pub fn keypress(&mut self, idx: usize, pressed: bool) {
        self.keys[idx] = pressed;
    }

    // -- Unchanged code omitted --
}
}

이 함수는 꽤 간단합니다. 눌린 키의 인덱스를 받아 값을 설정합니다. 이 함수를 press_key 함수와 release_key 함수로 나눌 수도 있었지만, 충분히 간단하므로 의도는 여전히 전달된다고 생각합니다. idx는 16 미만이어야 하며, 그렇지 않으면 프로그램이 패닉을 일으킨다는 점에 유의하십시오. 여기에 그 제약을 추가할 수도 있지만, 두 번 검사하는 대신 프런트엔드에서 처리하고 백엔드에서는 올바르게 처리되었다고 가정하겠습니다.

마지막으로, 게임 코드를 파일에서 RAM으로 불러와 실행할 수 있도록 하는 방법이 필요합니다. 이 부분은 프런트엔드에서 파일을 읽기 시작할 때 더 자세히 살펴보겠지만, 지금은 바이트 목록을 받아 RAM에 복사해야 합니다.

#![allow(unused)]
fn main() {
impl Emu {
    // -- Unchanged code omitted --

    pub fn load(&mut self, data: &[u8]) {
        let start = START_ADDR as usize;
        let end = (START_ADDR as usize) + data.len();
        self.ram[start..end].copy_from_slice(data);
    }

    // -- Unchanged code omitted --
}
}

이 함수는 입력으로 받은 data 슬라이스의 모든 값을 0x200부터 시작하는 RAM에 복사합니다. RAM의 처음 512바이트에는 게임 데이터가 들어가지 않으며, 그곳에 저장하는 문자 스프라이트 데이터를 제외하면 비어 있다는 점을 기억하십시오.

프런트엔드 설정

마침내 에뮬레이터의 프런트엔드를 설정하여 이것저것 테스트해 보고 (바라건대) 게임도 몇 가지 해 봅시다! 화려한 GUI 인터페이스를 만드는 것은 이 가이드의 범위를 벗어나므로, 단순히 에뮬레이터를 시작하고 명령줄 인수를 통해 플레이할 게임을 선택하도록 하겠습니다. 지금 바로 설정해 봅시다.

desktop/src/main.rs에서 게임 ROM 파일의 경로를 받기 위해 명령줄 인수를 읽어야 합니다. 추가 설정을 위해 여러 플래그를 만들 수도 있지만, 간단하게 정확히 하나의 인수, 즉 게임 경로만 요구하도록 하겠습니다. 인수의 개수가 이와 다르면 오류와 함께 종료합니다.

use std::env;

fn main() {
    let args: Vec<_> = env::args().collect();
    if args.len() != 2 {
        println!("Usage: cargo run path/to/game");
        return;
    }
}

이 코드는 전달된 모든 명령줄 매개변수를 벡터로 가져오며, 그 개수가 두 개가 아니면(프로그램 이름은 항상 args[0]에 저장됩니다) 올바른 입력 방법을 출력하고 종료합니다. 이제 사용자가 전달한 경로는 args[1]에 저장됩니다. 파일을 열려고 할 때 그것이 유효한 파일인지 확인해야 하지만, 그 전에 먼저 설정해야 할 다른 것들이 있습니다.

창 만들기

이 에뮬레이션 프로젝트에서는 SDL 라이브러리를 사용하여 게임 창을 만들고 그 창에 렌더링하겠습니다. SDL은 키 입력과 그리기를 잘 지원하는 훌륭한 그리기 라이브러리입니다. 설정하려면 약간의 보일러플레이트 코드가 필요하지만, 일단 그 작업을 마치면 에뮬레이션을 시작할 수 있습니다.

먼저 SDL을 프로젝트에 포함해야 합니다. desktop/Cargo.toml을 열고 의존성에 sdl2를 추가하십시오:

[dependencies]
chip8_core = { path = "../chip8_core" }
sdl2 = "^0.34.3

이제 다시 desktop/src/main.rs로 돌아와서 모든 것을 하나로 합치기 시작할 수 있습니다. 코어에서 정의한 공개 함수들이 필요하므로, use chip8_core::*로 Rust에 그 함수들이 필요하다고 알려 줍시다.

use chip8_core::*;
use std::env;

fn main() {
    let args: Vec<_> = env::args().collect();
    if args.len() != 2 {
        println!("Usage: cargo run path/to/game");
        return;
    }
}

chip8_core 안에서 화면 크기를 담는 공개 상수를 만들었는데, 지금 그 상수를 가져오고 있습니다. 하지만 64x32 크기의 게임 창은 요즘 모니터에서는 정말 작으므로, 크기를 조금 키워 봅시다. 여러 숫자로 실험해 본 결과 제 모니터에서는 15배 배율이 잘 맞았지만, 다른 값을 원하신다면 조정하셔도 됩니다.

#![allow(unused)]
fn main() {
const SCALE: u32 = 15;
const WINDOW_WIDTH: u32 = (SCREEN_WIDTH as u32) * SCALE;
const WINDOW_HEIGHT: u32 = (SCREEN_HEIGHT as u32) * SCALE;
}

SCREEN_WIDTH와 SCREEN_HEIGHT는 백엔드에서 정의한 공개 상수였으며, 이제 use chip8_core::* 문을 통해 이 크레이트에 포함되었다는 점을 떠올려 보십시오. SDL은 화면 크기가 usize가 아닌 u32이어야 하므로, 여기서 형 변환을 하겠습니다.

이제 SDL 창을 만들 차례입니다! 다음 코드는 단순히 새 SDL 컨텍스트를 만든 다음, 창 자체와 그 위에 그릴 캔버스를 만듭니다.

fn main() {
    // -- Unchanged code omitted --

    // Setup SDL
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();
    let window = video_subsystem
        .window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut canvas = window.into_canvas().present_vsync().build().unwrap();
    canvas.clear();
    canvas.present();
}

SDL을 초기화하고, 확대한 크기로 새 창을 만들도록 지시하겠습니다. 또한 창이 사용자 화면의 가운데에 생성되도록 하겠습니다. 그런 다음 VSYNC를 켠 상태로, 실제로 그림을 그릴 캔버스 객체를 가져오겠습니다. 이어서 캔버스를 지우고 사용자에게 보여 주십시오.

지금 실행해 보면(cargo run test처럼 테스트용 가짜 파일 이름을 지정하십시오) 창이 잠깐 나타났다가 닫히는 것을 볼 수 있습니다. 이는 SDL 창이 잠깐 생성되지만 곧 프로그램이 끝나면서 창이 닫히기 때문입니다. 프로그램이 바로 끝나지 않도록 메인 게임 루프를 만들어야 하며, 이왕 하는 김에 창을 닫으려고 할 때 프로그램을 종료하는 처리도 추가합시다(그렇지 않으면 작업 관리자에서 프로그램을 강제 종료해야 합니다).

SDL은 이벤트 펌프라는 것을 사용하여 루프마다 이벤트를 폴링합니다. 이를 확인하면 창을 닫으려 하거나 키를 누르는 것과 같은 특정 이벤트에 따라 다른 동작이 일어나게 할 수 있습니다. 지금은 창을 닫아야 할 때 프로그램이 메인 게임 루프를 빠져나오게만 하겠습니다.

SDL의 Event를 사용하겠다고 Rust에 알려 주어야 합니다:

#![allow(unused)]
fn main() {
use sdl2::event::Event;
}

그리고 main 함수를 수정하여 기본 게임 루프를 추가하십시오:

fn main() {
    // -- Unchanged code omitted --

    // Setup SDL
    let sdl_context = sdl2::init().unwrap();
    let video_subsystem = sdl_context.video().unwrap();
    let window = video_subsystem
        .window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
        .position_centered()
        .opengl()
        .build()
        .unwrap();

    let mut canvas = window.into_canvas().present_vsync().build().unwrap();
    canvas.clear();
    canvas.present();

    let mut event_pump = sdl_context.event_pump().unwrap();

    'gameloop: loop {
        for evt in event_pump.poll_iter() {
            match evt {
                Event::Quit{..} => {
                    break 'gameloop;
                },
                _ => ()
            }
        }
    }
}

이렇게 추가한 코드는 메인 게임 루프를 구성하며, 이 루프는 이벤트가 발생했는지 확인합니다. (창을 닫으려고 하여) Quit 이벤트가 감지되면 프로그램이 루프를 빠져나오고, 그 결과 프로그램이 종료됩니다. cargo run test로 다시 실행해 보면 ’Chip-8 에뮬레이터’라는 제목의 새 검은 창이 뜨는 것을 볼 수 있습니다. 창은 아무 문제 없이 정상적으로 닫힐 것입니다.

살아 움직입니다! 다음으로는 에뮬레이터의 chip8_core 백엔드를 초기화하고, 게임 파일을 열어 불러오겠습니다.

파일 불러오기

이제 프런트엔드에서 새 에뮬레이션 창을 만들 수 있으니, 백엔드도 실행되도록 만들 차례입니다. 다음 단계에서는 실제로 게임 파일을 읽어 들이고, 그 데이터를 백엔드에 넘겨 RAM에 저장하고 실행하도록 하겠습니다. 먼저, 데이터를 넘겨받을 백엔드 객체가 실제로 있어야 합니다. 계속해서 frontend/src/main.rs에서 에뮬레이션 객체를 만들어 봅시다. Emu 객체는 chip8_core의 다른 모든 공개 항목과 함께 이미 포함되어 있으므로, 바로 초기화할 수 있습니다.

fn main() {
    // -- Unchanged code omitted --

    let mut chip8 = Emu::new();

    'gameloop: loop {
        // -- Unchanged code omitted --
    }
}

Emu 객체 생성은 메인 게임 루프보다 앞에 두어야 합니다. 에뮬레이션 화면 그리기와 키 입력 처리가 그 루프 안에 들어갈 것이기 때문입니다. 게임 경로는 사용자가 넘겨준다는 점을 기억하십시오. 따라서 파일을 읽어 들이기 전에 해당 파일이 실제로 존재하는지도 확인해야 합니다. 파일을 열려면 먼저 main.rs에서 표준 라이브러리의 몇 가지 항목을 use해야 합니다.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::Read;
}

따로 설명할 필요가 없을 정도로 명확합니다. 다음으로, 명령줄 매개변수로 전달받은 파일을 열어 버퍼로 읽어 들인 다음, 그 데이터를 에뮬레이터 백엔드에 넘겨줍니다.

fn main() {
    // -- Unchanged code omitted --
    let mut chip8 = Emu::new();

    let mut rom = File::open(&args[1]).expect("Unable to open file");
    let mut buffer = Vec::new();

    rom.read_to_end(&mut buffer).unwrap();
    chip8.load(&buffer);
    // -- Unchanged code omitted --
}

여기서 몇 가지 짚고 넘어갈 점이 있습니다. 사용자가 준 경로의 파일을 Rust가 열지 못하면(아마 파일이 존재하지 않는 경우일 것입니다), expect 조건이 실패하고 프로그램은 해당 메시지를 출력하며 종료됩니다. 둘째로, 파일 경로를 백엔드에 넘겨 그곳에서 데이터를 불러올 수도 있지만, 파일을 읽는 일은 프런트엔드에 더 가까운 동작이므로 여기에 두는 편이 더 잘 맞습니다. 더 중요한 점은, 최종적으로 백엔드를 거의 또는 전혀 수정하지 않고 이 에뮬레이터를 웹 브라우저에서 동작하게 만드는 것이 우리의 계획이라는 것입니다. 브라우저가 파일을 읽는 방식은 파일 시스템이 파일을 읽는 방식과 매우 다르므로, 파일 읽기는 각 프런트엔드가 처리하도록 하고 데이터를 얻은 뒤에 넘기도록 하겠습니다.

에뮬레이터 실행과 화면 그리기

게임이 RAM에 로드되었고 메인 루프가 돌아가고 있습니다. 이제 백엔드에 명령어 처리를 시작하고 실제로 화면에 그리도록 지시해야 합니다. 기억하시겠지만, 에뮬레이터는 tick 함수가 호출될 때마다 클럭 사이클을 한 번 진행하므로, 이를 루프에 추가해 봅시다.

fn main() {
    // -- Unchanged code omitted --

    'gameloop: loop {
        for event in event_pump.poll_iter() {
            // -- Unchanged code omitted --
        }

        chip8.tick();
    }
}

이제 루프가 한 사이클을 돌 때마다 에뮬레이터는 명령어를 하나씩 더 실행합니다. 너무 쉬워 보일 수도 있지만, 화면 버퍼 수정을 포함하여 백엔드의 모든 부분을 tick이 움직이도록 구성해 두었기 때문입니다. 백엔드에서 화면 데이터를 가져와 SDL 창을 갱신하는 함수를 추가해 봅시다. 먼저, SDL에서 몇 가지 요소를 추가로 use해야 합니다:

#![allow(unused)]
fn main() {
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::Canvas;
use sdl2::video::Window;
}

다음은 함수입니다. 이 함수는 Emu 객체에 대한 참조와 SDL 캔버스에 대한 가변 참조를 받습니다. 화면을 그리려면 몇 단계를 거쳐야 합니다. 먼저, 이전 프레임을 지우기 위해 캔버스를 비웁니다. 그런 다음 화면 버퍼를 순회하면서 값이 참일 때마다 흰색 사각형을 그립니다. Chip-8은 흑백만 지원하므로, 화면을 검은색으로 비워 두면 흰색 사각형을 그리는 일만 신경 쓰면 됩니다.

#![allow(unused)]
fn main() {
fn draw_screen(emu: &Emu, canvas: &mut Canvas<Window>) {
    // Clear canvas as black
    canvas.set_draw_color(Color::RGB(0, 0, 0));
    canvas.clear();

    let screen_buf = emu.get_display();
    // Now set draw color to white, iterate through each point and see if it should be drawn
    canvas.set_draw_color(Color::RGB(255, 255, 255));
    for (i, pixel) in screen_buf.iter().enumerate() {
        if *pixel {
            // Convert our 1D array's index into a 2D (x,y) position
            let x = (i % SCREEN_WIDTH) as u32;
            let y = (i / SCREEN_WIDTH) as u32;

            // Draw a rectangle at (x,y), scaled up by our SCALE value
            let rect = Rect::new((x * SCALE) as i32, (y * SCALE) as i32, SCALE, SCALE);
            canvas.fill_rect(rect).unwrap();
        }
    }
    canvas.present();
}
}

이 함수를 요약하면, 1차원 화면 버퍼 배열을 가져와 순회하는 것입니다. 흰색 픽셀(참 값)을 찾으면 화면상의 2차원 좌표 (x, y)를 계산하고, SCALE 배율만큼 확대한 사각형을 그 위치에 그립니다.

이 함수는 메인 루프에서 tick 바로 다음에 호출하겠습니다.

fn main() {
    // -- Unchanged code omitted --

    'gameloop: loop {
        for event in event_pump.poll_iter() {
            // -- Unchanged code omitted --
        }

        chip8.tick();
        draw_screen(&chip8, &mut canvas);
    }
}

여러분 중 몇몇은 이 부분에서 고개를 갸웃할지도 모릅니다. 화면은 초당 60프레임, 즉 60 Hz로 갱신되어야 합니다. 에뮬레이션은 분명 그보다 더 빠르게 진행되어야 하지 않겠습니까? 그렇습니다. 하지만 그 생각은 잠시 접어 둡시다. 타이밍을 바로잡기 전에, 먼저 제대로 동작하기는 하는지부터 확인하겠습니다.

내려받은 Chip-8 게임이 있다면, 다음과 같이 실제 게임으로 에뮬레이터를 실행해 보십시오.

$ cargo run path/to/game

모든 것이 잘 되었다면 창이 나타나고 게임이 렌더링되며 실행되는 것을 볼 수 있을 것입니다! 여러분만의 에뮬레이터로 여기까지 온 것에 성취감을 느끼셔도 좋습니다.

앞서 언급했듯이, 에뮬레이션 tick 속도는 아마도 캔버스 갱신 주기보다 빨라야 할 것입니다. 게임이 실행되는 모습을 보면 약간 느리게 느껴질 수 있습니다. 지금은 명령어 하나를 실행하고, 화면에 그리고, 이를 반복합니다. 아시다시피 화면에 의미 있는 변화를 주려면 여러 개의 명령어가 필요합니다. 이 문제를 해결하기 위해, 다시 그리기 전에 에뮬레이터가 여러 번 tick하도록 하겠습니다.

이제부터는 다소 실험적인 영역입니다. Chip-8 명세에는 시스템이 실제로 얼마나 빠르게 동작해야 하는지에 대한 언급이 전혀 없습니다. 지금 상태 그대로 두어 60 Hz로 동작하게 하는 것도 타당한 해법입니다(그렇게 하셔도 좋습니다). 화면 그리기로 넘어가기 전에 tick 함수를 여러 번 반복 실행하도록 하기만 하면 됩니다. 개인적으로 저는(그리고 제가 살펴본 다른 에뮬레이터들도) 프레임당 10번의 tick이 적당한 지점이라고 생각합니다.

const TICKS_PER_FRAME: usize = 10;
// -- Unchanged code omitted --

fn main() {
    // -- Unchanged code omitted --

    'gameloop: loop {
        for event in event_pump.poll_iter() {
            // -- Unchanged code omitted --
        }

        for _ in 0..TICKS_PER_FRAME {
            chip8.tick();
        }
        draw_screen(&chip8, &mut canvas);
    }
}

여러분 중 몇몇은 이것이 다소 꼼수 같다고 느낄 수도 있습니다(저도 어느 정도 동의합니다). 하지만 이는 더 ‘정교한’ 시스템들이 동작하는 방식이기도 합니다. 다만 그런 CPU들은 보통 화면에 다시 그릴 준비가 되었음을 알리는 방법을 갖추고 있다는 점이 다릅니다. Chip-8에는 그러한 메커니즘도, 정해진 클럭 속도도 없으므로 이것이 이 작업을 해내는 더 쉬운 방법입니다.

다시 실행해 보면 얼마 진행되지 않아 멈춘다는 것을 알아차릴 수 있습니다. 이는 아마 두 타이머를 전혀 갱신하지 않아서, 에뮬레이터가 게임에서 얼마나 많은 시간이 흘렀는지 전혀 알지 못하기 때문일 것입니다. 앞서 타이머는 클럭 속도가 아니라 프레임당 한 번씩 동작한다고 말씀드렸으므로, 화면을 갱신하는 지점에서 타이머도 함께 갱신할 수 있습니다.

fn main() {
    // -- Unchanged code omitted --

    'gameloop: loop {
        for event in event_pump.poll_iter() {
            // -- Unchanged code omitted --
        }

        for _ in 0..TICKS_PER_FRAME {
            chip8.tick();
        }
        chip8.tick_timers();
        draw_screen(&chip8, &mut canvas);
    }
}

아직 구현할 것이 몇 가지 남아 있지만(우선 게임을 실제로 조작할 수 없습니다) 훌륭한 출발이며, 이제 마지막 단계에 접어들었습니다!

사용자 입력 추가하기

드디어 Chip-8 게임을 화면에 렌더링할 수 있게 되었지만, 조작할 방법이 없으므로 게임을 제대로 진행할 수는 없습니다. 다행히 SDL은 키보드 입력 읽기를 지원하므로, 이를 변환하여 백엔드 에뮬레이션에 전달할 수 있습니다.

다시 짚어 보면, Chip-8 시스템은 16개의 서로 다른 키를 지원합니다. 이 키들은 보통 4x4 격자로 배치되며, 0-9 키는 전화기처럼 배열되고 A-F 키가 그 주위를 둘러쌉니다. 키를 원하는 어떤 배치로든 구성하셔도 좋지만, 일부 게임 개발자들은 게임 입력을 정할 때 키가 격자 형태로 배치되어 있다고 가정했기 때문에, 그렇지 않으면 일부 게임은 플레이하기 불편할 수 있습니다. 우리 에뮬레이터에서는 아래와 같이 QWERTY 키보드의 왼쪽 키들을 입력으로 사용하겠습니다.

SDL의 키 타입을 에뮬레이터에 보낼 값으로 변환하는 함수를 만들어 봅시다. 다음과 같이 SDL 키보드 지원을 main.rs로 가져와야 합니다.

#![allow(unused)]
fn main() {
use sdl2::keyboard::Keycode;
}

이제 Keycode를 받아 선택적인 u8 값을 출력하는 새 함수를 만들겠습니다. 유효한 키는 16개뿐이므로, 유효한 출력값은 Some으로 감싸고 사용자가 Chip-8 키가 아닌 키를 누르면 None을 반환하겠습니다. 이 함수는 위 그림에 나타난 대로 모든 유효한 키를 패턴 매칭하는 것에 불과합니다.

#![allow(unused)]
fn main() {
fn key2btn(key: Keycode) -> Option<usize> {
    match key {
        Keycode::Num1 =>    Some(0x1),
        Keycode::Num2 =>    Some(0x2),
        Keycode::Num3 =>    Some(0x3),
        Keycode::Num4 =>    Some(0xC),
        Keycode::Q =>       Some(0x4),
        Keycode::W =>       Some(0x5),
        Keycode::E =>       Some(0x6),
        Keycode::R =>       Some(0xD),
        Keycode::A =>       Some(0x7),
        Keycode::S =>       Some(0x8),
        Keycode::D =>       Some(0x9),
        Keycode::F =>       Some(0xE),
        Keycode::Z =>       Some(0xA),
        Keycode::X =>       Some(0x0),
        Keycode::C =>       Some(0xB),
        Keycode::V =>       Some(0xF),
        _ =>                None,
    }
}
}

다음으로 메인 이벤트 루프에 이벤트 두 개를 추가하겠습니다. 하나는 KeyDown용이고 다른 하나는 KeyUp용입니다. 각 이벤트는 눌린 키가 key2btn 함수에서 Some 값을 반환하는지 확인하고, 그렇다면 앞서 정의한 공개 함수 keypress를 통해 그 값을 에뮬레이터에 전달합니다. 두 이벤트의 유일한 차이는 키를 설정하는지 해제하는지입니다.

fn main() {
    // -- Unchanged code omitted --
    'gameloop: loop {
        for evt in event_pump.poll_iter() {
            match evt {
                Event::Quit{..} => {
                    break 'gameloop;
                },
                Event::KeyDown{keycode: Some(key), ..} => {
                    if let Some(k) = key2btn(key) {
                        chip8.keypress(k, true);
                    }
                },
                Event::KeyUp{keycode: Some(key), ..} => {
                    if let Some(k) = key2btn(key) {
                        chip8.keypress(k, false);
                    }
                },
                _ => ()
            }

        for _ in 0..TICKS_PER_FRAME {
            chip8.tick();
        }
        chip8.tick_timers();
        draw_screen(&chip8, &mut canvas);
    }
    // -- Unchanged code omitted --
}

if let 문은 오른쪽 값이 왼쪽과 일치할 때, 즉 key2btn(key)가 Some 값을 반환할 때만 충족됩니다. 그런 다음 꺼낸 값이 k에 저장됩니다.

흔히 쓰이는 에뮬레이터 기능인 Escape 키를 눌러 프로그램을 종료하는 기능도 추가해 봅시다. 이 기능은 Quit 이벤트와 함께 추가하겠습니다.

fn main() {
    // -- Unchanged code omitted --
    'gameloop: loop {
        for evt in event_pump.poll_iter() {
            match evt {
                Event::Quit{..} | Event::KeyDown{keycode: Some(Keycode::Escape), ..}=> {
                    break 'gameloop;
                },
                Event::KeyDown{keycode: Some(key), ..} => {
                    if let Some(k) = key2btn(key) {
                        chip8.keypress(k, true);
                    }
                },
                Event::KeyUp{keycode: Some(key), ..} => {
                    if let Some(k) = key2btn(key) {
                        chip8.keypress(k, false);
                    }
                },
                _ => ()
            }
        }

        for _ in 0..TICKS_PER_FRAME {
            chip8.tick();
        }
        chip8.tick_timers();
        draw_screen(&chip8, &mut canvas);
    }
    // -- Unchanged code omitted --
}

찾은 key 변수를 확인하던 다른 키 이벤트와 달리, 여기서는 Escape 키를 사용해 종료하려고 합니다. 에뮬레이터에 이 기능을 넣고 싶지 않거나 다른 키 입력 기능을 원한다면 얼마든지 그렇게 하셔도 됩니다.

이것으로 끝입니다! 이제 Chip-8 에뮬레이터의 데스크톱 프런트엔드가 완성되었습니다. 명령줄 매개변수로 게임을 지정하고, 게임을 불러와 실행하고, 출력을 화면에 표시하고, 사용자 입력을 처리할 수 있습니다.

에뮬레이션이 어떻게 동작하는지 이해하는 데 도움이 되었기를 바랍니다. Chip-8은 꽤 단순한 시스템이지만, 여기서 다룬 기법은 모든 에뮬레이션이 동작하는 방식의 기초가 됩니다.

하지만 이 가이드는 아직 끝나지 않았습니다! 다음 절에서는 WebAssembly로 에뮬레이터를 빌드하고 웹 브라우저에서 실행하는 방법을 다루겠습니다.