원더보이처럼 섬을 달리고, 줄어드는 체력을 열매로 채우고, 꽃봉오리에서 무기를 꺼내는 8비트 횡스크롤 액션을 빈 캔버스부터 만든다. 장마다 게임의 부품을 하나씩 직접 만져 보며 만들고, 마지막 장에서 그 부품을 모두 합친 게임을 플레이한다.
We build an 8-bit side-scroller in the style of Wonder Boy from an empty canvas: run across an island, keep a draining vitality meter topped up with fruit, and pull weapons out of flower buds. Each chapter builds one part of the game, and you can try every part as you go. The last chapter puts all the parts together so you can play the game.
완성된 게임이 몇 가지 단순한 규칙으로 스스로 플레이하는 데모 화면. 그림, 레벨, 소리, 물리 모두 이 페이지의 코드와 도구로 만들었다.
The finished game playing itself by a few simple rules. The art, level, sound, and physics were all made with the code and tools on this page.
0.1무엇을 만드나What we're making
원작 원더보이(1986)에서 가져오는 것은 규칙뿐이다. 시간이 지나면 줄어드는 체력 게이지, 열매를 먹어 버티는 긴장감, 알에서 나오는 무기, 발에 걸리는 돌, 뒤로 돌아갈 수 없는 스크롤. 이런 규칙은 장르의 문법이라 누구나 쓸 수 있다.
캐릭터, 그림, 음악, 이름은 모두 새로 만들었다. 주인공은 머리에 새싹이 난 아이 보리다. 돌도끼 대신 도토리를 던지고, 알 대신 꽃봉오리를 연다.
From the original Wonder Boy (1986) we borrow only the rules: a vitality meter that drains over time, the tension of eating fruit to survive, weapons that come out of eggs, rocks that trip you, and a screen that never scrolls back. Rules like these are the grammar of the genre, and anyone can use them.
The character, art, music, and names are all new. The hero is Bori, a kid with a sprout growing out of his head. He throws acorns instead of stone axes and opens flower buds instead of eggs.
0.2하드웨어를 정하고 시작한다Pick the hardware first
제약이 있어야 결정이 빨라진다. 이 노트는 원더보이가 이식된 세가 마스터 시스템(SMS)의 사양을 기준으로 삼는다. 진짜 하드웨어를 에뮬레이션하지는 않지만, 같은 숫자 안에서 그림과 소리를 만든다. 그 시절 하드웨어가 지금의 컴퓨터와 어떻게 다른지는 8장에서 자세히 비교한다.
Constraints make decisions faster. This notebook takes its numbers from the Sega Master System (SMS), which got a port of Wonder Boy. We don't emulate the real hardware, but we make the art and sound within the same limits. Chapter 8 compares that hardware with today's computers in detail.
항목
SMS 사양
이 노트에서
화면
256 × 192
캔버스를 이 크기로 그리고 픽셀 그대로 키운다
색
64색 중 16색 × 2벌
배경용과 스프라이트용 팔레트 (2장)
스프라이트
한 줄에 8개까지
넘치면 깜빡이는 이유 (2장)
배경 타일
8 × 8 픽셀
16 × 16으로 묶은 메타타일 (3장)
소리
사각파 3 + 노이즈 1
웹 오디오로 흉내 낸 PSG 칩 (5장)
화면 갱신
60Hz (NTSC)
1/60초 고정 업데이트 (1장)
Item
SMS spec
In this notebook
Screen
256 × 192
The canvas is drawn at this size and enlarged pixel for pixel
Color
2 × 16 of 64 colors
A background palette and a sprite palette (Chapter 2)
Sprites
Up to 8 per line
Why sprites flicker when there are too many (Chapter 2)
도구에서 바꾼 것은 데이터가 되고, 엔진은 그 데이터를 읽어 게임을 돌린다. 이 페이지에서는 그 흐름이 실제로 이어져 있다. 4장의 픽셀 에디터로 보리의 옷 색을 바꾸면 모든 장의 게임 화면에서 보리의 옷이 바뀐다. 바꾼 내용은 이 브라우저에만 저장되고, 왼쪽 아래 목록에서 확인하거나 각 도구의 기본값 버튼으로 되돌릴 수 있다.
Whatever you change in a tool becomes data, and the engine reads that data to run the game. On this page that pipeline is actually connected. Change the color of Bori's shirt in the Chapter 4 pixel editor and his shirt changes on every game screen in every chapter. Your changes are saved only in this browser; you can review them in the list at the bottom left or undo them with each tool's reset button.
0.4조작Controls
←→
이동. 계속 누르면 점점 빨라진다
ZSpace↑
점프. 길게 누를수록 높이 뛴다
X
도토리 던지기. 첫 꽃봉오리에서 주머니를 얻은 뒤부터 쓸 수 있고, 한 번 얻으면 죽어도 남는다
↓ + 점프
통나무 발판 아래로 내려가기
Enter · P
시작 · 일시정지
←→
Move. Hold to pick up speed
ZSpace↑
Jump. Hold longer to jump higher
X
Throw an acorn. Available once you get the pouch from the first flower bud, and you keep it even after dying
↓ + jump
Drop down through a log platform
Enter · P
Start · pause
게임 화면을 한 번 클릭해야 키보드 입력이 그 화면으로 간다. 휴대폰에서는 화면 아래의 버튼을 쓴다.
Click a game screen once so that keyboard input goes to it. On a phone, use the buttons under the screen.
1장 · 게임 루프
Chapter 1 · The game loop
1초에 60번, 같은 일을 되풀이한다
The same thing, 60 times a second
게임은 정지 화면을 아주 빨리 넘기는 기계다. 입력을 읽고, 세상을 한 걸음 진행하고, 그림을 그린다. 이 되풀이의 박자를 어떻게 맞추느냐가 점프 높이부터 리플레이까지 모든 것의 바탕이 된다.
A game is a machine for flipping through still pictures very fast. It reads the input, advances the world one step, and draws. How you keep the beat of that loop underlies everything from jump height to replays.
브라우저에서는 requestAnimationFrame이 박자를 준다. 모니터가 화면을 새로 그릴 때마다 함수를 한 번 불러 준다. 60Hz 모니터에서는 1/60초마다, 144Hz 모니터에서는 1/144초마다다.
가장 단순한 루프는 아래와 같다. 문제는 한 번 불릴 때 세상을 얼마나 진행할 것인가다. 매번 똑같은 거리를 움직이면 144Hz 모니터에서는 게임이 2.4배 빨라진다.
In a browser, requestAnimationFrame keeps the beat. It calls your function once each time the display redraws: every 1/60 of a second on a 60 Hz monitor, every 1/144 of a second on a 144 Hz one.
The simplest loop looks like this. The question is how far to advance the world on each call. Move the same distance every time and the game runs 2.4 times faster on a 144 Hz monitor.
가장 단순한 루프
function frame() {
readInput(); // 이번 프레임에 눌린 키
update(); // 세상을 한 걸음 진행
render(); // 지금 상태를 그린다
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
The simplest loop
function frame() {
readInput(); // keys pressed this frame
update(); // advance the world one step
render(); // draw the current state
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
1.2지난 시간만큼 움직이면 될까Just move by the elapsed time?
흔한 첫 해결책은 지난 프레임부터 흐른 시간 Δt를 곱하는 것이다. y += vy * dt. 평균 속도는 맞아진다. 하지만 Δt가 커지면 한 번에 크게 건너뛰어서 점프 높이가 달라지고, 얇은 발판을 그냥 뚫고 지나간다.
아래 두 화면은 똑같은 점프를 똑같은 충돌 코드로 처리한다. 왼쪽은 가변 Δt, 오른쪽은 1/60초로 고정한 Δt다. 화면 갱신률을 낮추거나 렉을 일으켜 보자.
The usual first fix is to multiply by Δt, the time since the last frame: y += vy * dt. That gets the average speed right. But when Δt grows, each step is a big leap, so jump heights change and thin platforms get skipped entirely.
The two screens below run the same jump through the same collision code. The left one uses a variable Δt; the right one uses a Δt fixed at 1/60 of a second. Lower the refresh rate or cause a lag spike.
고정 Δt는 실제로 흐른 시간을 통에 모아 두었다가 1/60초씩 잘라서 쓴다. 30Hz 화면이면 한 번 그릴 때 업데이트를 두 번 하고, 144Hz 화면이면 대부분 0번, 가끔 1번 한다. 어떤 기기에서든 세상은 정확히 같은 계단을 밟는다. 그래서 점프 높이가 같고, 같은 입력을 다시 넣으면 같은 결과가 나온다. 리플레이, 네트워크 동기화, 물리 값 조정이 모두 이 성질에 기댄다.
A fixed Δt collects the real elapsed time in a bucket and spends it in slices of 1/60 of a second. On a 30 Hz display it runs two updates per drawn frame; on a 144 Hz display it usually runs none and occasionally one. On every device the world climbs exactly the same staircase, so the jump height is the same, and feeding in the same inputs again gives the same result. Replays, network sync, and tuning physics values all rely on this.
1.3누산기와 보간The accumulator and interpolation
시간을 모아 두는 통을 누산기(accumulator)라고 부른다. 아래 타임라인은 실제 시간의 흐름이다. 세로선은 화면을 그린 순간, 점은 업데이트가 일어난 순간, 맨 아래 톱니 모양은 통에 남은 시간이다.
업데이트보다 그리기가 잦으면 같은 위치를 몇 번 그리다 한 번에 건너뛰어서 끊겨 보인다. 통에 남은 시간의 비율 α(= 누산기 ÷ 1/60초)만큼 이전 위치와 현재 위치 사이를 섞어 그리면 부드러워진다. 이것이 보간이다. 물리 갱신률을 10Hz로 낮추고 보간을 껐다 켜 보자.
The bucket of time is called the accumulator. The timeline below shows real time passing. Vertical lines are the moments the screen was drawn, dots are updates, and the sawtooth at the bottom is the time left in the bucket.
When frames are drawn more often than the world updates, the same position is drawn a few times and then jumps, which looks choppy. Drawing each object between its previous and current position, blended by the fraction α of a step left in the bucket (accumulator ÷ 1/60 s), makes the motion smooth. That's interpolation. Set the physics rate to 10 Hz and toggle interpolation.
이 페이지의 모든 데모가 쓰는 루프loop.js
tick(now) {
let dt = (now - this.last) / 1000;
this.last = now;
if (dt > 0.25) dt = 0.25; // 탭을 숨겼다 돌아와도 폭주하지 않게
this.acc += dt * this.timeScale; // 흐른 시간을 통에 모은다
let n = 0;
while (this.acc >= this.step) { // 1/60초씩 잘라서
if (n >= 6) { this.acc = 0; break; } // 너무 밀리면 버린다
this.update(); // 세상을 한 걸음
this.acc -= this.step;
n++;
}
this.render(this.acc / this.step); // α: 다음 걸음까지 얼마나 왔나
requestAnimationFrame(this.tick);
}
The loop every demo on this page usesloop.js
tick(now) {
let dt = (now - this.last) / 1000;
this.last = now;
if (dt > 0.25) dt = 0.25; // don't run wild after a hidden tab
this.acc += dt * this.timeScale; // collect the elapsed time
let n = 0;
while (this.acc >= this.step) { // spend it in 1/60 s slices
if (n >= 6) { this.acc = 0; break; } // too far behind: drop it
this.update(); // one step of the world
this.acc -= this.step;
n++;
}
this.render(this.acc / this.step); // α: how far toward the next step
requestAnimationFrame(this.tick);
}
dt 상한이 없으면 탭을 10초 숨겼다 돌아온 순간 업데이트를 600번 몰아서 하느라 화면이 얼어 버린다. 따라잡는 데 실제 시간보다 오래 걸리면 밀린 일이 계속 늘어나는데, 이것을 '죽음의 나선'이라고 부른다.
Without a cap on dt, coming back to a tab hidden for 10 seconds runs 600 updates at once and freezes the screen. If catching up takes longer than real time, the backlog keeps growing. That's called the spiral of death.
이 엔진에서 속도와 중력을 '픽셀/프레임' 단위로 적는 것도 고정 스텝 덕분이다. vy += 0.25는 "1/60초마다 아래쪽 속도가 0.25픽셀/프레임씩 늘어난다"는 뜻이고, 어느 기기에서 돌려도 같다. 옛날 게임기는 화면 주사에 맞춰 정확히 1초에 60번 코드가 돌았기 때문에 처음부터 이렇게 짰다.
The fixed step is also why this engine writes speed and gravity in pixels per frame. vy += 0.25 means "every 1/60 of a second, the downward speed grows by 0.25 pixels per frame," and it means the same on every device. Old consoles ran their code exactly 60 times a second, in step with the display, so games were written this way from the start.
2장 · 스프라이트와 애니메이션
Chapter 2 · Sprites and animation
256칸에 번호를 적는 일
Writing a number in 256 cells
보리는 가로세로 16픽셀, 256칸짜리 그림이다. 칸마다 색을 적는 대신 팔레트의 번호를 적는다. 이 결정 하나가 용량, 색 바꾸기, 깜빡임 연출까지 많은 것을 정한다.
Bori is a 16 × 16 picture, 256 cells in all. Instead of a color, each cell holds a number into a palette. That one decision shapes a lot: memory use, recoloring, even flicker effects.
2.164색 중에서 16색16 colors out of 64
마스터 시스템은 빨강, 초록, 파랑을 각각 2비트(00, 55, AA, FF의 4단계)로 표현한다. 4 × 4 × 4 = 64색이 전부다. 그중 16색을 골라 팔레트에 올리고, 그림의 각 칸에는 0~15 사이 번호만 적는다. 한 칸이 4비트이므로 16×16 그림 하나는 128바이트다. 칸마다 24비트 색을 적으면 768바이트가 된다.
The Master System stores red, green, and blue in 2 bits each (four levels: 00, 55, AA, FF). That makes 4 × 4 × 4 = 64 colors in total. You choose 16 of them for a palette, and each cell of a picture holds only a number from 0 to 15. At 4 bits per cell, a 16 × 16 picture takes 128 bytes. Storing a 24-bit color in every cell would take 768.
번호 0은 투명이다. 스프라이트를 배경 위에 그릴 때 0번 칸은 건너뛴다. 이 노트는 실제 하드웨어처럼 배경용과 스프라이트용 팔레트를 한 벌씩 둔다.
Number 0 means transparent: when a sprite is drawn over the background, its 0 cells are skipped. Like the real hardware, this notebook keeps one palette for backgrounds and one for sprites.
2.2스프라이트 시트와 애니메이션 표Sprite sheets and animation tables
모든 동작 그림을 한 장에 늘어놓은 것이 스프라이트 시트다. 애니메이션은 "어떤 프레임을 몇 틱 동안 보여 줄지" 적은 표일 뿐이다. 달리기는 4장을 6틱씩, 서 있기는 150틱 뒤에 8틱 동안 눈을 감는다. 표를 바꾸면 코드를 고치지 않고 움직임이 바뀐다.
A sprite sheet lays out every pose on one image. An animation is just a table of which frame to show for how many ticks. Running shows four frames for 6 ticks each; standing blinks for 8 ticks after 150. Change the table and the motion changes without touching the code.
애니메이션 표sprites.js
const ANIMS = {
idle: { loop: true, seq: [['idle', 150], ['blink', 8], ['idle', 40], ['blink', 6]] },
run: { loop: true, seq: [['run1', 6], ['run2', 6], ['run3', 6], ['run4', 6]] },
jump: { loop: false, seq: [['jump', 1]] },
};
// t = 이 상태에 들어온 뒤 지난 틱 수
function animFrame(name, t) {
const a = ANIMS[name];
const total = a.seq.reduce((s, f) => s + f[1], 0);
let tt = a.loop ? t % total : Math.min(t, total - 1);
for (const [key, ticks] of a.seq) {
if (tt < ticks) return key;
tt -= ticks;
}
}
Animation tablesprites.js
const ANIMS = {
idle: { loop: true, seq: [['idle', 150], ['blink', 8], ['idle', 40], ['blink', 6]] },
run: { loop: true, seq: [['run1', 6], ['run2', 6], ['run3', 6], ['run4', 6]] },
jump: { loop: false, seq: [['jump', 1]] },
};
// t = ticks since entering this state
function animFrame(name, t) {
const a = ANIMS[name];
const total = a.seq.reduce((s, f) => s + f[1], 0);
let tt = a.loop ? t % total : Math.min(t, total - 1);
for (const [key, ticks] of a.seq) {
if (tt < ticks) return key;
tt -= ticks;
}
}
그림의 테두리가 곧 몸의 경계는 아니다. 충돌에 쓰는 상자(히트박스)는 그림보다 작게 잡는다. 보리의 그림은 16×16이지만 히트박스는 10×14다. 머리 위 새싹이 적에 스쳤다고 죽으면 억울하기 때문이다. 왼쪽을 볼 때는 그림을 좌우로 뒤집어 그리므로 오른쪽을 보는 그림만 있으면 된다. 실제 마스터 시스템의 스프라이트는 뒤집을 수 없어서 왼쪽 그림도 따로 저장해야 했다(8장).
The edge of the picture isn't the edge of the body. The box used for collisions, the hitbox, is smaller than the picture: Bori's picture is 16 × 16, but his hitbox is 10 × 14. Dying because an enemy brushed the sprout on your head would feel unfair. When he faces left the picture is drawn mirrored, so only the right-facing frames are needed. Real Master System sprites couldn't be flipped, so games had to store left-facing frames too (Chapter 8).
2.3팔레트 스왑Palette swaps
그림에는 번호만 있으니 팔레트만 바꿔 끼우면 같은 데이터가 다른 색으로 나온다. 2인용 캐릭터, 독에 걸린 모습, 무적 상태의 번쩍임, 적을 맞혔을 때의 하얀 번쩍임이 모두 추가 그림 없이 팔레트 교체로 만들어진다.
Since the picture holds only numbers, swapping in a different palette shows the same data in different colors. The second player's look, the poisoned look, the flashing of invincibility, and the white flash of a hit enemy are all palette swaps, with no extra pictures.
2.4한 줄에 여덟 개Eight to a line
마스터 시스템은 화면의 한 가로줄(스캔라인)에 스프라이트를 8개까지만 그린다. 아홉 번째부터는 그 줄에서 사라진다. 적이 한 줄에 몰리면 일부가 잘려 보이는 이유다. 당시 게임은 매 프레임 스프라이트를 그리는 순서를 돌려서 사라지는 대상을 번갈아 바꿨다. 사람 눈에는 깜빡임으로 보이지만, 적어도 모두 보이기는 한다.
The Master System draws at most eight sprites on any one horizontal line of the screen, a scanline. From the ninth on, sprites vanish on that line, which is why enemies bunched on one line appear cut off. Games of the time rotated the order in which sprites were drawn every frame, so a different sprite disappeared each time. To the eye it looks like flicker, but at least everything shows up some of the time.
하드웨어 스프라이트는 폭이 8픽셀이라 16픽셀짜리 벌 한 마리는 스프라이트 2개를 쓴다. 한 줄에 벌 넷이면 한계에 닿는다.
Hardware sprites are 8 pixels wide, so one 16-pixel bee uses two of them. Four bees on a line hit the limit.
지금의 캔버스에는 이런 한계가 없다. 그래도 그리는 순서는 정해야 한다. 이 엔진은 하늘 → 구름 → 먼 산 → 언덕 → 타일 → 아이템과 적 → 보리 → 도토리와 파편 → 점수판 순서로 덧그린다. 나중에 그린 것이 위에 보인다.
A modern canvas has no such limit, but you still need a drawing order. This engine paints sky → clouds → mountains → hills → tiles → items and enemies → Bori → acorns and particles → scoreboard. Whatever is drawn later appears on top.
3장 · 타일맵, 패럴랙스, 카메라
Chapter 3 · Tile maps, parallax, camera
먼 산은 천천히, 땅은 빠르게
Distant mountains slow, the ground fast
3,072픽셀 길이의 섬을 그림 한 장으로 들고 다니지 않는다. 16×16 타일 몇 종류를 지도에 따라 찍는다. 그 뒤로 속도가 다른 배경 여러 장을 겹쳐 깊이를 만들고, 카메라가 보리를 따라간다.
We don't carry a 3,072-pixel island around as one picture. We stamp a handful of 16 × 16 tiles according to a map. Behind it, several background layers moving at different speeds create depth, and a camera follows Bori.
3.1타일맵: 숫자로 된 지도Tile maps: a map made of numbers
레벨은 가로 192칸, 세로 12칸의 숫자 배열이다. 숫자는 타일 종류이고, 종류마다 '단단함', '위에서만 밟힘', '닿으면 위험' 같은 속성이 붙는다. 그림과 규칙이 같은 번호로 묶여 있으니 충돌 판정은 지도에서 숫자 하나를 읽는 일로 끝난다. 아래 지도에서 칸을 눌러 보자.
The level is an array of numbers, 192 cells wide and 12 tall. Each number is a tile type, and each type carries properties such as solid, one-way from above, or hazard. Since the picture and the rules share the same number, a collision check is just reading one number from the map. Select cells on the map below.
땅 칸은 위, 오른쪽, 아래, 왼쪽 이웃이 같은 땅인지에 따라 모양이 바뀐다. 위가 비면 잔디를 올리고, 옆이 비면 가장자리를 어둡게 칠한다. 레벨을 만드는 사람은 '땅'만 칠하면 되고 모양은 엔진이 고른다. 4장에서 직접 만져 본다.
A ground cell changes shape depending on whether its neighbors above, right, below, and left are also ground. With nothing above it gets grass; with an open side it gets a darker edge. A level designer just paints "ground" and the engine picks the shape. You'll try it yourself in Chapter 4.
3.2패럴랙스: 속도로 만드는 깊이Parallax: depth from speed
달리는 차창 밖을 보면 가까운 것은 빨리, 먼 것은 느리게 지나간다. 배경 레이어마다 카메라 이동량에 다른 배율을 곱하면 같은 효과가 난다. 이 게임은 구름 0.1, 먼 산 0.25, 언덕 0.5, 타일 1.0이다. 배율을 바꿔 보고, 레이어를 펼쳐서 겹친 모습을 확인해 보자.
From the window of a moving car, nearby things rush past and distant ones drift by slowly. Multiplying the camera movement by a different factor for each background layer gives the same effect. This game uses 0.1 for the clouds, 0.25 for the mountains, 0.5 for the hills, and 1.0 for the tiles. Change the factors, and spread the layers out to see how they stack.
레이어 하나 그리기 (폭 512픽셀을 이어 붙여 반복)tiles.js
const off = Math.floor((camX * factor) % 512);
ctx.drawImage(layer, -off, 0); // 왼쪽 조각
ctx.drawImage(layer, 512 - off, 0); // 이어지는 오른쪽 조각
Drawing one layer (a 512-pixel strip, repeated)tiles.js
const off = Math.floor((camX * factor) % 512);
ctx.drawImage(layer, -off, 0); // left piece
ctx.drawImage(layer, 512 - off, 0); // the piece that continues it
진짜 마스터 시스템에는 배경 레이어가 한 장뿐이다. 그래서 화면을 그리는 도중 특정 줄에서 가로 스크롤 값을 바꿔 위아래 띠가 다른 속도로 움직이게 했다. 래스터 스크롤이라는 요령이다. 8장에서 직접 본다.
The real Master System has just one background layer. Games changed the horizontal scroll value partway down the screen so that bands moved at different speeds. The trick is called raster scrolling, and Chapter 8 shows it in action.
3.3카메라: 언제 따라갈 것인가The camera: when to follow
카메라가 캐릭터를 늘 정중앙에 두면 작은 움직임에도 화면이 흔들려 어지럽다. 화면 안에 '데드존'을 두고 캐릭터가 그 밖으로 나갈 때만 따라간다. 원더보이처럼 앞으로만 스크롤하게 막으면 지나온 길로 돌아갈 수 없다. 레벨 설계가 단순해지고, 지나간 적을 계속 관리할 필요도 없다. 화면을 클릭하고 움직여 보자.
A camera that keeps the character dead center shakes the screen with every small move, which is dizzying. Instead, put a dead zone in the screen and follow only when the character leaves it. If the screen only scrolls forward, as in Wonder Boy, there's no going back. That keeps level design simple, and enemies you've passed never need managing again. Click the screen and move around.
데드존 카메라world.js
const sx = playerX - cam.x; // 화면 안에서 보리의 위치
if (sx > deadRight) target = playerX - deadRight;
else if (sx < deadLeft) target = playerX - deadLeft;
cam.x += (target - cam.x) * smooth; // 1이면 즉시, 작을수록 느긋하게
if (oneWay) cam.x = Math.max(cam.x, cam.minX); // 뒤로는 못 간다
cam.x = clamp(cam.x, 0, levelWidth - 256);
cam.minX = Math.max(cam.minX, cam.x);
A dead-zone cameraworld.js
const sx = playerX - cam.x; // Bori's position on screen
if (sx > deadRight) target = playerX - deadRight;
else if (sx < deadLeft) target = playerX - deadLeft;
cam.x += (target - cam.x) * smooth; // 1 = instant, smaller = lazier
if (oneWay) cam.x = Math.max(cam.x, cam.minX); // never scroll back
cam.x = clamp(cam.x, 0, levelWidth - 256);
cam.minX = Math.max(cam.minX, cam.x);
4장 · 리소스 저작 도구
Chapter 4 · Authoring tools
게임보다 도구를 먼저 만든다
Build the tools before the game
게임 데이터를 코드에 직접 적으면 열매 하나 옮길 때마다 프로그래머가 필요하다. 도구를 만들면 그림과 레벨을 만드는 사람이 직접 바꾸고 바로 확인한다. 이 장의 도구로 바꾼 것은 이 페이지의 모든 게임 화면에 즉시 반영된다.
If game data lives in the code, moving one piece of fruit needs a programmer. With tools, the people making the art and levels change things themselves and see the result right away. Whatever you change with this chapter's tools shows up immediately on every game screen on this page.
4.1도구 → 데이터 → 엔진Tool → data → engine
도구는 사람이 다루기 편한 모습으로 보여 주고, 저장할 때는 엔진이 읽기 편한 모습으로 바꾼다. 픽셀 에디터는 확대된 격자를 보여 주지만 저장하는 것은 256개의 팔레트 번호다. 레벨 에디터는 그림을 보여 주지만 저장하는 것은 글자 지도와 압축된 숫자열이다. 둘 사이의 변환 코드가 작을수록 도구를 고치기 쉽다.
좋은 도구의 조건은 단순하다. 바꾼 결과를 몇 초 안에 게임 속에서 확인할 수 있어야 한다. 이 장의 에디터는 모두 그렇게 만들었다.
A tool presents data in a form people find easy to work with, and saves it in a form the engine finds easy to read. The pixel editor shows an enlarged grid but saves 256 palette numbers. The level editor shows pictures but saves a character map and a compressed list of numbers. The smaller the conversion code between the two, the easier the tool is to change.
The test of a good tool is simple: you should see your change in the game within seconds. Every editor in this chapter works that way.
4.2픽셀 에디터The pixel editor
칸을 누르면 고른 번호가 칠해진다. 연필, 지우개(0번 = 투명), 채우기, 스포이드, 좌우 대칭 그리기, 이전 프레임 겹쳐 보기(어니언 스킨), 되돌리기를 넣었다. 굵은 선은 하드웨어 타일 경계인 8픽셀마다 긋는다. 오른쪽 미리보기는 지금 고치는 시트로 애니메이션을 돌린다. 시트를 바꾸면 적과 아이템도 고칠 수 있다.
Select a cell to paint it with the chosen number. The editor has a pencil, an eraser (0 = transparent), fill, an eyedropper, mirrored drawing, onion skinning (the previous frame shown faintly underneath), and undo. The thick lines mark the 8-pixel boundaries of hardware tiles. The preview on the right animates the sheet you're editing. Switch sheets to edit the enemies and items too.
보리의 옷을 파랗게 칠하고 9장에 가 보자. 게임 속 보리의 옷도 파랗다.
Paint Bori's shirt blue, then visit Chapter 9. His shirt is blue in the game too.
4.3오토타일Autotiling
이웃 네 칸이 같은 땅인지를 비트 하나씩으로 적는다. 위 1, 오른쪽 2, 아래 4, 왼쪽 8. 네 비트를 더한 0~15가 그 칸의 모양 번호다. 아래 격자를 눌러 땅을 칠하고 지워 보자. 숫자가 바뀌며 잔디와 가장자리가 저절로 붙는다.
Record with one bit each whether the four neighbors are also ground: up 1, right 2, down 4, left 8. The four bits add up to a number from 0 to 15, the cell's shape number. Select cells in the grid below to paint and erase ground. The numbers change, and grass and edges appear on their own.
마스크 계산tiles.js
const same = (x, y) => level.tile(x, y) === GROUND;
const mask = (same(tx, ty - 1) ? 1 : 0) // 위
| (same(tx + 1, ty) ? 2 : 0) // 오른쪽
| (same(tx, ty + 1) ? 4 : 0) // 아래
| (same(tx - 1, ty) ? 8 : 0); // 왼쪽
return groundTiles[mask];
Computing the masktiles.js
const same = (x, y) => level.tile(x, y) === GROUND;
const mask = (same(tx, ty - 1) ? 1 : 0) // up
| (same(tx + 1, ty) ? 2 : 0) // right
| (same(tx, ty + 1) ? 4 : 0) // down
| (same(tx - 1, ty) ? 8 : 0); // left
return groundTiles[mask];
4.4레벨 에디터The level editor
지형 타일이나 물체를 고른 뒤 지도에 칠한다. 아래 띠 지도를 누르면 그 위치로 이동한다. '여기서 테스트'를 누르면 같은 자리에서 바로 플레이가 시작된다. 편집과 플레이 사이에 내보내기나 빌드 같은 단계가 없다는 점이 핵심이다.
바꾼 레벨은 이 브라우저에 자동으로 저장되어 다른 장의 게임에도 쓰인다. 실수했다면 되돌리기(Ctrl+Z)를 쓰고, 처음부터 다시 하려면 '처음 레벨로 되돌리기'를 두 번 누른다.
Choose a terrain tile or an object and paint it onto the map. Select a spot on the strip map below to jump there. "Test from here" starts playing right at that spot. The point is that there's no export or build step between editing and playing.
Your edits are saved in this browser automatically and used by the games in other chapters. If you make a mistake, use undo (Ctrl+Z); to start over, press "Reset to the original level" twice.
4.5저장 형식The file format
처음 레벨은 텍스트 에디터로 그렸다. 글자 하나가 한 칸이다. 사람이 읽기 좋고, 버전 관리 도구에서 바뀐 곳이 잘 보인다. 게임에 넣을 때는 숫자로 바꾸고 압축한다. 같은 타일이 이어지는 구간을 (개수, 타일) 쌍으로 적는 런 렝스 인코딩(RLE)이다. 세로 열 단위로 묶은 이유는 스크롤하며 새로 보이는 열만 풀면 되기 때문이다.
The original level was drawn in a text editor, one character per cell. It's easy for people to read, and version control shows exactly what changed. For the game it's turned into numbers and compressed with run-length encoding (RLE), which writes each run of identical tiles as a (count, tile) pair. Runs go down the columns because a scrolling game only needs to unpack the columns coming into view.
5장 · 효과음과 배경음악
Chapter 5 · Sound effects and music
사각파 셋, 노이즈 하나
Three square waves and some noise
마스터 시스템의 소리는 PSG라는 칩 하나에서 나온다. 사각파 채널 셋과 노이즈 채널 하나, 볼륨은 16단계가 전부다. 음악과 효과음이 이 네 채널을 나눠 쓴다. 웹 오디오로 이 칩을 흉내 내고, 그 위에 효과음 디자이너와 작은 트래커를 만든다.
All of the Master System's sound comes from one chip, the PSG: three square-wave channels, one noise channel, and 16 volume levels. Music and sound effects share those four channels. We imitate the chip with Web Audio and build a sound-effect designer and a small tracker on top of it.
5.1칩이 내는 소리The sound of the chip
사각파 채널은 10비트 숫자 N 하나로 음높이를 정한다. 주파수는 3,579,545 ÷ (32 × N)Hz다. N이 클수록 낮은 음인데 최댓값이 1023이라 약 109Hz(A2 근처)보다 낮은 음은 낼 수 없다. 이 시대 게임의 베이스가 높게 들리는 이유다. 높은 음에서는 N 한 칸 차이가 반음 간격에 가까워져서 음정이 살짝 어긋난다. 건반을 눌러 실제 N과 오차를 확인해 보자.
A square-wave channel sets its pitch with a single 10-bit number, N. The frequency is 3,579,545 ÷ (32 × N) Hz. A larger N gives a lower note, and since N tops out at 1023, nothing below about 109 Hz (near A2) can be played. That's why bass lines in games of this era sound high. At the top of the range, a step of one in N approaches the size of a semitone, so notes come out slightly out of tune. Press the keys to see the real N and the error.
볼륨은 4비트 감쇠값이고 한 단계가 2dB다. 노이즈 채널은 16비트 시프트 레지스터의 비트 두 개를 XOR해 되먹이는 방식(LFSR)으로 '치익' 소리를 만든다. 드럼과 폭발음이 모두 이 채널에서 나온다. 이 노트의 노이즈도 같은 방식으로 계산했다.
Volume is a 4-bit attenuation value, 2 dB per step. The noise channel makes its hiss with a 16-bit shift register that XORs two of its bits and feeds the result back in (an LFSR). Drums and explosions all come from this channel. This notebook computes its noise the same way.
5.2효과음 디자이너The sound-effect designer
칩에는 소리의 모양을 만드는 엔벨로프 장치가 없다. 효과음은 '프레임마다 음높이와 볼륨을 얼마로 쓸지' 적은 표다. 표를 손으로 적는 대신 시작 음, 미끄러짐, 비브라토, 아르페지오, 볼륨 모양 같은 손잡이 몇 개로 표를 만든다. 그래프의 선이 음높이, 막대가 볼륨이다. 목록의 효과음은 게임이 실제로 쓰는 것이고, 고치면 바로 게임 소리가 바뀐다.
The chip has no envelope hardware to shape a sound. A sound effect is a table of the pitch and volume to write on each frame. Instead of writing the table by hand, a few knobs build it: start pitch, slide, vibrato, arpeggio, and the volume shape. In the graph, the line is pitch and the bars are volume. These are the effects the game actually uses, so editing one changes the game's sound immediately.
5.3배경음악: 패턴과 순서표Music: patterns and an order list
음악도 같은 원리다. 행(row)마다 어떤 음을 시작할지 적은 '패턴'을 만들고, 패턴을 틀 순서를 '순서표'에 적는다. 속도는 BPM 대신 '한 행을 몇 프레임 동안 유지할지'로 정한다. 7이면 한 행이 7/60초이고 4행이 한 박이니 약 129 BPM이다.
채널이 셋뿐이라 화음은 아르페지오로 흉내 낸다. 도, 미, 솔을 프레임마다 번갈아 빠르게 바꾸면 귀가 화음처럼 듣는다. 화음 채널에는 근음만 찍으면 C장조 안에서 장3화음과 단3화음을 골라 돌린다.
Music works the same way. A pattern lists which note starts on each row, and an order list says which patterns to play in sequence. Tempo isn't set in BPM but in how many frames each row lasts. At 7, a row lasts 7/60 of a second, and with four rows to a beat that's about 129 BPM.
With only three tone channels, chords are faked with arpeggios. Cycling C, E, and G every frame makes the ear hear a chord. Put just the root note in the harmony channel and the driver picks the major or minor triad that fits the key of C major.
5.4게임에서 소리를 내는 법Making sound in a game
자바스크립트 타이머는 몇 밀리초씩 늦기 일쑤다. 그래서 음을 '지금' 내지 않고 오디오 장치의 시계로 0.12초 앞까지 미리 예약한다. 25ms마다 깨어나 아직 예약하지 않은 프레임을 채워 넣는다. 화면의 재생 위치도 이 시계를 보고 맞춘다.
JavaScript timers routinely run a few milliseconds late. So notes aren't played "now"; they're scheduled up to 0.12 seconds ahead on the audio device's clock. Every 25 ms the driver wakes up and fills in the frames it hasn't scheduled yet. The playback position on screen follows the same clock.
미리 예약하는 드라이버audio.js
schedule() {
const until = ctx.currentTime + 0.12; // 0.12초 앞까지
while (this.nextT < until) {
this.frame(this.nextT); // 그 시각에 쓸 레지스터 값을 예약
this.nextT += 1 / 60;
}
}
setInterval(() => this.schedule(), 25);
A driver that schedules aheadaudio.js
schedule() {
const until = ctx.currentTime + 0.12; // up to 0.12 s ahead
while (this.nextT < until) {
this.frame(this.nextT); // schedule that moment's register values
this.nextT += 1 / 60;
}
}
setInterval(() => this.schedule(), 25);
효과음이 울리는 동안에는 음악 채널 하나를 잠시 빌린다. 사각파 효과음은 화음 채널을, 노이즈 효과음은 드럼 채널을 끄고 그 자리에서 소리를 낸다. 채널이 네 개뿐인 하드웨어에서는 이렇게 할 수밖에 없었다. 효과음끼리는 우선순위를 매긴다. 죽는 소리가 나는 중에 점프 소리가 끼어들면 무시한다. 위 트래커에서 음악을 틀고 효과음 버튼을 눌러 빨갛게 표시되는 채널을 확인해 보자. 8장에서는 요즘 방식인 믹싱과 덕킹으로 바꿔 들어 볼 수 있다.
브라우저는 사용자가 페이지와 한 번 상호작용하기 전에는 소리를 내지 못하게 막는다. 그래서 오디오는 첫 클릭이나 키 입력에서 깨운다.
While a sound effect plays, it borrows a music channel. A square-wave effect silences the harmony channel and a noise effect silences the drums, then plays in their place. With only four channels in the hardware, there was no other way. Effects also have priorities: if a jump sound tries to cut into the death sound, it's ignored. Start the music in the tracker above and press the effect buttons to see which channel turns red. Chapter 8 lets you switch to the modern approach, mixing and ducking, and hear the difference.
Browsers block sound until the user has interacted with the page once, so the audio wakes up on the first click or key press.
6장 · 게임 물리
Chapter 6 · Game physics
숫자 몇 개가 점프의 모양을 정한다
A few numbers shape the jump
플랫포머의 물리는 현실을 흉내 내지 않고 '조작하는 느낌'을 만든다. 단위는 초가 아니라 프레임, 거리는 미터가 아니라 픽셀이다. 숫자 몇 개가 점프의 포물선을 정하고, 그 포물선이 레벨의 구덩이 폭을 정한다.
Platformer physics doesn't imitate reality; it creates a feel under your fingers. Time is measured in frames instead of seconds, and distance in pixels instead of meters. A few numbers set the arc of the jump, and that arc sets how wide the level's pits can be.
6.1점프 궤적 실험실The jump arc lab
매 프레임 속도에 중력을 더하고, 위치에 속도를 더한다. 이것이 전부다. 버튼을 일찍 떼면 상승 속도를 잘라서 낮게 뛴다. 아래 그래프는 최고 속도로 달리면서 버튼을 끝까지 누른 점프, 8프레임만 누른 점프, 톡 친 점프를 프레임마다 점으로 찍은 것이다. 격자 한 칸이 타일 하나다.
Every frame, add gravity to the velocity and the velocity to the position. That's all there is. Release the button early and the upward speed is cut, so the jump is lower. The graph below plots, one dot per frame, three jumps at full running speed: the button held all the way, held for 8 frames, and tapped. One grid square is one tile.
매 프레임world.js
if (jumpPressed && canJump) vy = -jumpV;
if (jumping && !jumpHeld && vy < -jumpCut) vy = -jumpCut; // 가변 점프
vy = Math.min(vy + gravity, maxFall); // 중력과 종단 속도
moveX(player, vx); // 축을 나눠 이동하고
moveY(player, vy); // 각각 충돌 처리
Every frameworld.js
if (jumpPressed && canJump) vy = -jumpV;
if (jumping && !jumpHeld && vy < -jumpCut) vy = -jumpCut; // variable jump
vy = Math.min(vy + gravity, maxFall); // gravity and terminal velocity
moveX(player, vx); // move one axis at a time
moveY(player, vy); // and resolve collisions for each
여기서 바꾼 값은 이 페이지의 모든 게임에 그대로 쓰인다. 중력을 올리고 9장에 가면 보리가 무거워져 있다. 최고 높이가 3칸이 안 되면 레벨 중간의 높은 블록에 오르지 못한다. 물리 값과 레벨 설계는 함께 움직인다.
The values you set here are used by every game on this page. Raise the gravity and visit Chapter 9: Bori feels heavier. If the peak height drops below 3 tiles, he can't climb the tall blocks in the middle of the level. Physics values and level design move together.
6.2타일 충돌을 한 단계씩Tile collision, one step at a time
움직이는 상자와 타일의 충돌은 x축과 y축을 나눠 처리한다. 먼저 x로만 움직이고, 겹친 벽이 있으면 벽 앞으로 밀어낸다. 그다음 y로 움직이고 바닥이나 천장으로 밀어낸다. 대각선으로 한 번에 움직이면 벽에 걸린 건지 바닥에 걸린 건지 가리기 어렵지만, 축을 나누면 단계마다 답이 하나뿐이다.
상자를 끌어 옮기고, 화살표 끝을 끌어 속도를 정한 뒤 '한 단계'를 눌러 보자. 빈 곳을 누르면 블록을 놓거나 지운다.
Collisions between a moving box and the tiles are resolved one axis at a time. First move along x only, and if a wall overlaps, push the box back out in front of it. Then move along y and push out against the floor or ceiling. Moving diagonally in one go makes it hard to tell whether you hit a wall or a floor; splitting the axes leaves exactly one answer at each step.
Drag the box, drag the tip of the arrow to set its velocity, then press "One step." Select an empty spot to place or remove a block.
6.3손맛: 코요테 타임과 점프 버퍼Feel: coyote time and the jump buffer
사람의 손은 프레임 단위로 정확하지 않다. 발판 끝에서 한 프레임 늦게 눌렀다고 점프가 안 되면 억울하다. 그래서 발판을 벗어난 뒤 몇 프레임 동안은 점프를 받아 준다. 만화에서 절벽 밖으로 달려 나간 코요테가 잠깐 허공에 서 있는 장면에서 따온 이름이다. 반대로 땅에 닿기 직전에 누른 점프는 기억해 뒀다가 착지하자마자 실행한다. 이것이 점프 버퍼다.
아래 화면에서 둘을 끄고 켜 보자. 아래 띠는 프레임마다의 점프 버튼과 땅 접촉 기록이다. 늦게 눌렀는데도 성공한 점프에는 표시가 붙는다.
Human hands aren't frame-accurate. Missing a jump because you pressed one frame after leaving a ledge feels unfair. So for a few frames after you walk off a platform, a jump is still accepted. The name comes from the cartoon coyote who runs off a cliff and hangs in the air for a moment. In the other direction, a jump pressed just before landing is remembered and performed the moment you touch down. That's the jump buffer.
Toggle both on the screen below. The strip underneath records the jump button and ground contact for every frame. Jumps that succeeded despite a late or early press get a label.
6.4터널링Tunneling
한 프레임에 벽 두께보다 많이 움직이면, 벽 앞에 있다가 다음 프레임에 벽 뒤에 있게 된다. 겹친 순간이 없으니 충돌도 없다. 보리는 스프링을 밟아도 한 프레임에 9픽셀을 넘게 움직이지 않으니 16픽셀 타일을 뚫을 일이 없지만 빠른 탄환은 다르다. 이동을 잘게 나누거나(서브스텝), 이동 경로 전체를 선분으로 검사해서(스윕) 막는다.
Move farther than a wall is thick in a single frame and you're in front of the wall in one frame and behind it the next. They never overlap, so there's no collision. Even off a spring, Bori never moves more than 9 pixels a frame, so he can't tunnel through a 16-pixel tile, but a fast bullet can. The fixes are to split the move into small substeps, or to test the whole path as a line segment (a sweep).
6.5위에서만 밟히는 발판과 스프링One-way platforms and springs
통나무는 아래에서는 통과하고 위에서만 밟힌다. 조건은 한 줄이다. "움직이기 전에 발이 발판 윗면보다 위에 있었는가." 아래 화살표와 점프를 같이 누르면 잠깐 이 검사를 꺼서 아래로 내려간다. 스프링은 떨어지는 중에 윗면에 닿으면 속도를 강하게 위로 바꿔 준다. 6.3의 화면에서 둘 다 시험할 수 있다.
A log can be passed through from below and stood on only from above. The condition is one line: were the feet above the platform's top before this move? Pressing down and jump together turns the check off briefly so you drop through. A spring, when you land on it while falling, flips your velocity sharply upward. You can try both on the 6.3 screen.
원웨이 발판 판정physics.js
const prevBottom = b.y + b.h; // 움직이기 전 발의 위치
b.y += dy;
const land = solid ||
(oneWay && !b.dropThrough && prevBottom <= tileTop);
The one-way platform testphysics.js
const prevBottom = b.y + b.h; // where the feet were before the move
b.y += dy;
const land = solid ||
(oneWay && !b.dropThrough && prevBottom <= tileTop);
7장 · 입력, 상태 기계, 연출, 디버그
Chapter 7 · Input, state machines, juice, debugging
돌아가는 게임에서 손에 붙는 게임으로
From a game that runs to a game that feels right
작동하는 게임과 재미있는 게임 사이에는 작은 기술이 많다. 입력을 프레임 단위로 다루기, 캐릭터를 상태 기계로 정리하기, 손맛을 더하는 연출, 버그를 잡는 디버그 화면, 그리고 장면 전환과 저장.
Between a game that works and a game that's fun lie many small techniques: handling input frame by frame, organizing a character as a state machine, adding juice, building a debug view to catch bugs, and switching scenes and saving.
7.1입력: 눌림, 유지, 뗌Input: pressed, held, released
키보드 이벤트는 아무 때나 도착한다. 게임은 업데이트를 시작할 때 한 번 키 상태를 찍어 두고 지난 프레임과 비교한다. 지금 눌려 있고 전에는 아니었으면 '눌림', 반대면 '뗌'이다. 점프는 '눌림'에, 달리기는 '유지'에, 가변 점프는 '뗌'에 반응한다. 키 이름 대신 점프, 던지기 같은 '행동' 이름으로 바꿔 두면 키 설정이나 게임패드, 터치 버튼을 붙이기 쉽다.
Keyboard events arrive whenever they like. The game takes one snapshot of the keys at the start of each update and compares it with the previous frame. Down now but not before means "pressed"; the reverse means "released." Jumping reacts to pressed, running to held, and the variable jump to released. Mapping keys to actions such as jump and throw, instead of key names, makes it easy to add key remapping, a gamepad, or touch buttons later.
프레임마다 한 번input.js
poll() {
for (const a of ACTIONS) {
this.prev[a] = this.cur[a];
this.cur[a] = this.raw[a] || this.latch[a]; // latch: 아주 짧은 탭도 놓치지 않게
this.latch[a] = false;
}
}
pressed(a) { return this.cur[a] && !this.prev[a]; }
released(a) { return !this.cur[a] && this.prev[a]; }
Once per frameinput.js
poll() {
for (const a of ACTIONS) {
this.prev[a] = this.cur[a];
this.cur[a] = this.raw[a] || this.latch[a]; // latch: don't miss a very short tap
this.latch[a] = false;
}
}
pressed(a) { return this.cur[a] && !this.prev[a]; }
released(a) { return !this.cur[a] && this.prev[a]; }
7.2상태 기계란 무엇인가What a state machine is
상태 기계(정확히는 유한 상태 기계, FSM)는 세 가지로 정해진다. 있을 수 있는 상태의 목록, 들어올 수 있는 입력의 목록, 그리고 "이 상태에서 이 입력이 오면 다음 상태는 이것"이라는 전이 규칙이다. 기계는 어느 순간이든 정확히 한 상태에 있고, 입력이 올 때마다 규칙을 보고 다음 상태로 옮겨 간다. 기억하는 것은 '지금 상태' 하나뿐이다.
컴퓨터 과학 교과서에 자주 나오는 예는 지하철 개찰구다. 잠겨 있을 때 동전을 넣으면 열리고, 열려 있을 때 밀고 지나가면 다시 잠긴다. 잠긴 개찰구를 밀어 봐야 그대로 잠겨 있고, 열린 개찰구에 동전을 더 넣어도 열린 채 그대로다. 버튼으로 입력을 넣어 보자.
A state machine (strictly, a finite state machine, or FSM) is defined by three things: a list of possible states, a list of possible inputs, and transition rules of the form "in this state, on this input, go to that state." At any moment the machine is in exactly one state, and each input moves it to the next state according to the rules. The only thing it remembers is which state it is in.
The classic textbook example is a subway turnstile. When it's locked, a coin unlocks it. When it's unlocked, pushing through locks it again. Pushing a locked turnstile leaves it locked, and more coins in an unlocked one leave it unlocked. Use the buttons to send it inputs.
그림(상태 전이도)과 표(상태 전이표)는 같은 규칙을 다르게 적은 것이다. 코드로 옮길 때는 표를 그대로 자료구조로 쓰면 된다. 동작을 바꾸고 싶으면 표만 고치고, 표에 없는 조합은 처음부터 일어날 수 없다.
The diagram (a state diagram) and the table (a transition table) are two ways of writing the same rules. In code you can use the table itself as a data structure. To change the behavior you edit the table, and a combination that isn't in the table can't happen at all.
상태 전이표를 그대로 코드로turnstile.js
// rules[지금 상태][입력] = 다음 상태
const rules = {
locked: { coin: 'unlocked', push: 'locked' },
unlocked: { coin: 'unlocked', push: 'locked' },
};
let state = 'locked';
function send(input) {
state = rules[state][input];
}
send('coin'); // state === 'unlocked'
send('push'); // state === 'locked'
The transition table, as codeturnstile.js
// rules[current state][input] = next state
const rules = {
locked: { coin: 'unlocked', push: 'locked' },
unlocked: { coin: 'unlocked', push: 'locked' },
};
let state = 'locked';
function send(input) {
state = rules[state][input];
}
send('coin'); // state === 'unlocked'
send('push'); // state === 'locked'
플래그 여러 개 대신 상태 하나One state instead of many flags
상태 기계 없이 캐릭터를 짜면 보통 참·거짓 변수(플래그)를 하나씩 늘려 간다. isJumping, isFalling, isStumbling, isDead. 플래그가 네 개면 조합은 2⁴ = 16가지인데, 실제로 말이 되는 조합은 다섯 가지뿐이다. 나머지 열한 가지는 '죽었는데 점프 중' 같은, 일어나면 안 되는 상태다. 코드 어딘가에서 플래그 하나를 끄는 것을 잊으면 바로 그 상태가 된다.
상태 변수 하나(state = 'jump')로 바꾸면 한 번에 한 상태만 가질 수 있으니 이런 조합은 아예 적을 수가 없다. 버그가 생길 자리를 구조로 막는 셈이다. 아래에서 플래그를 켜고 꺼 보자.
Without a state machine, a character usually grows a pile of true/false variables, or flags: isJumping, isFalling, isStumbling, isDead. Four flags allow 2⁴ = 16 combinations, but only five of them make sense. The other eleven are states that must never happen, like "dead but jumping." Forget to clear one flag somewhere in the code and you're in one of them.
Replace the flags with a single state variable (state = 'jump') and the character can only be in one state at a time, so those combinations can't even be written down. The structure itself rules the bug out. Toggle the flags below.
게임 밖의 상태 기계State machines outside games
상태 기계는 게임보다 훨씬 넓게 쓰인다.
문자열 처리. 정규 표현식은 상태 기계로 바꿔 실행할 수 있다. 컴파일러에서 소스 코드를 단어(토큰)로 자르는 렉서도 상태 기계다.
네트워크. TCP 연결은 LISTEN, SYN-SENT, ESTABLISHED, TIME-WAIT 같은 상태를 오가는 상태 기계로 정의되어 있다.
화면과 장치. 버튼의 보통·눌림·비활성 모양, 신호등, 자판기, 엘리베이터 제어가 모두 상태 기계다.
계산 이론. 유한 상태 기계는 가장 단순한 계산 모델이다. 기억이 '지금 상태' 하나뿐이라 괄호 짝 맞추기처럼 끝없이 세어야 하는 일은 하지 못한다. 여기에 스택을 붙이면 푸시다운 오토마타, 무한한 테이프를 붙이면 튜링 기계가 된다.
상태가 수십 개로 늘어나면 비슷한 상태를 묶는 계층형 상태 기계나, 상태마다 객체를 하나씩 두는 상태 패턴으로 정리한다. 보리 정도의 캐릭터는 문자열 하나와 switch 문이면 충분하다.
State machines show up far beyond games.
Text processing. A regular expression can be turned into a state machine and run. The lexer that splits source code into tokens inside a compiler is one too.
Networking. A TCP connection is defined as a state machine that moves between states such as LISTEN, SYN-SENT, ESTABLISHED, and TIME-WAIT.
Interfaces and devices. A button's normal, pressed, and disabled looks, traffic lights, vending machines, and elevator controllers are all state machines.
Theory of computation. The finite state machine is the simplest model of computation. Its only memory is the current state, so it can't do anything that needs unbounded counting, such as checking that parentheses are balanced. Give it a stack and you get a pushdown automaton; give it an infinite tape and you get a Turing machine.
When states grow into the dozens, people group related states into a hierarchical state machine, or give each state its own object (the State pattern). A character like Bori needs no more than a string and a switch statement.
7.3보리의 상태 기계Bori's state machine
이제 보리를 상태 기계로 짜 보자. 보리가 있을 수 있는 상태는 서기, 달리기, 점프, 낙하, 비틀, 죽음의 여섯 가지다. 매 프레임 지금 상태에 맞는 일을 하고, 조건이 맞으면 다음 상태로 옮긴다. 화면을 클릭하고 보리를 움직여 보자. 아래 그림에서 지금 상태가 칠해지고, 방금 지나간 화살표가 빨개진다.
Now let's build Bori as a state machine. He can be in one of six states: idle, run, jump, fall, stumble, and dead. Every frame he does his current state's work and moves to the next state when its condition holds. Click the screen and move Bori around. The current state lights up in the diagram below, and the arrow just taken turns red.
상태마다 할 일과 다음 상태world.js
// 상태는 문자열 하나. 한 번에 하나만 가질 수 있다.
if (p.state === 'stumble') {
p.stumbleT--;
if (p.stumbleT <= 0) setState('idle'); // 24프레임 뒤 다시 서기
} else {
runAndJump(p); // 서기·달리기·점프·낙하는 조작이 같다
if (!p.onGround) setState(p.vy < 0 ? 'jump' : 'fall');
else setState(Math.abs(p.vx) > 0.15 ? 'run' : 'idle');
}
Each state's work and its next stateworld.js
// The state is one string, so there can only be one at a time.
if (p.state === 'stumble') {
p.stumbleT--;
if (p.stumbleT <= 0) setState('idle'); // back on his feet after 24 frames
} else {
runAndJump(p); // idle, run, jump and fall share the controls
if (!p.onGround) setState(p.vy < 0 ? 'jump' : 'fall');
else setState(Math.abs(p.vx) > 0.15 ? 'run' : 'idle');
}
모든 것을 상태로 만들 필요는 없다. 보리는 달리면서도 점프하면서도 도토리를 던질 수 있으니, 던지기는 상태가 아니라 12프레임짜리 타이머로 겹쳐 둔다. 동시에 일어날 수 있는 일은 같은 상태 기계에 넣지 않는 것이 기준이다.
Not everything needs to be a state. Bori can throw while running or jumping, so throwing is a 12-frame timer layered on top instead of a state. The rule of thumb: things that can happen at the same time don't belong in the same state machine.
7.4연출: 같은 게임, 다른 손맛Juice: same game, different feel
적을 맞힌 순간 세상을 4프레임 멈추는 히트스톱, 화면 흔들림, 튀는 파편, 착지할 때 찌그러지는 몸, 하얗게 번쩍이는 적, 떠오르는 점수. 하나하나는 작지만 다 끄면 같은 게임이 밋밋해진다. 아래 화면에서 도토리를 던져 적을 맞히며 하나씩 꺼 보자. 쓰러진 적은 잠시 뒤 다시 나타난다.
Hitstop that freezes the world for four frames when you land a hit, screen shake, flying debris, a body that squashes on landing, an enemy flashing white, a floating score. Each is small, but turn them all off and the same game goes flat. Throw acorns at the enemies below and switch the effects off one at a time. Defeated enemies come back after a moment.
7.5디버그 화면The debug view
보이지 않는 것은 고칠 수 없다. 히트박스, 타일 격자, 충돌 검사한 칸, 속도 벡터, 상태 이름을 화면에 덧그리는 스위치를 처음부터 만들어 둔다. 시간 배율을 0.25로 낮추거나 한 프레임씩 넘기면 '가끔' 일어나는 버그를 눈으로 잡을 수 있다. 위 화면 아래의 디버그 스위치를 켜 보자. 고정 타임스텝이라 느리게 돌려도 결과가 똑같다.
You can't fix what you can't see. Build switches from day one that draw hitboxes, the tile grid, the cells checked for collision, the velocity vector, and the state name over the screen. Slow time to 0.25× or step one frame at a time and you can catch the bugs that only happen "sometimes." Try the debug switches under the screen above. Thanks to the fixed timestep, slowing down doesn't change the outcome.
7.6비트맵 폰트A bitmap font
점수판 글자는 폰트 파일이 아니라 5×7 비트 그림을 모은 것이다. 글자 그림을 아틀라스 한 장에 늘어놓고, 문자열을 돌면서 해당 위치를 잘라 6픽셀 간격으로 찍는다. 이 페이지 각 장의 번호도 같은 폰트로 그렸다.
The scoreboard letters aren't a font file. They're a set of 5×7 bit pictures. The glyphs are laid out on one atlas, and drawing a string means cutting each glyph out of the atlas and stamping it 6 pixels apart. The chapter numbers on this page use the same font.
7.7장면과 저장Scenes and saving
게임 전체도 작은 상태 기계다. 타이틀 → 준비 → 플레이 → 죽음 → (목숨이 남으면) 준비, 아니면 게임 오버 → 타이틀. 골에 닿으면 클리어 장면에서 남은 체력을 점수로 바꾼다. 장면마다 업데이트와 그리기를 따로 둔다. 최고 점수처럼 다음에도 남아야 하는 값은 브라우저 저장소에 적는다. 사적인 창에서는 저장이 실패할 수 있으니 항상 실패를 감싼다.
The game as a whole is a small state machine too: title → ready → play → dying → ready again if lives remain, otherwise game over → title. Reaching the goal leads to a clear scene that converts the remaining vitality into points. Each scene has its own update and drawing code. Values that must survive until next time, like the best score, go into browser storage, and every write is wrapped in a try block because saving can fail in a private window.
장면 전환world.js
switch (this.scene) {
case 'title': if (pressed('start')) this.newGame(); break;
case 'ready': if (this.sceneT >= 70) this.scene = 'play'; break;
case 'play': this.updatePlay(); break; // 죽으면 'dying'
case 'dying': this.updateDying(); break; // 목숨이 남으면 'ready'
case 'clear': this.updateClear(); break; // 남은 체력을 점수로
case 'gameover': if (this.sceneT > 260) this.toTitle(); break;
}
function saveBest(score) {
try { localStorage.setItem('best', score); }
catch (e) { /* 저장할 수 없는 환경: 조용히 넘어간다 */ }
}
Switching scenesworld.js
switch (this.scene) {
case 'title': if (pressed('start')) this.newGame(); break;
case 'ready': if (this.sceneT >= 70) this.scene = 'play'; break;
case 'play': this.updatePlay(); break; // dying → 'dying'
case 'dying': this.updateDying(); break; // lives left → 'ready'
case 'clear': this.updateClear(); break; // vitality becomes points
case 'gameover': if (this.sceneT > 260) this.toTitle(); break;
}
function saveBest(score) {
try { localStorage.setItem('best', score); }
catch (e) { /* storage unavailable: carry on quietly */ }
}
8장 · 하드웨어: 그때와 지금
Chapter 8 · Hardware, then and now
1986년의 칩과 지금의 GPU
A 1986 chip and today's GPU
이 노트는 마스터 시스템의 숫자를 빌려 왔지만, 실제 하드웨어는 일하는 방식부터 지금의 컴퓨터와 다르다. 그 시절에는 전용 칩이 그림과 소리를 직접 만들었고, 지금은 범용 프로세서가 계산한다. 차이를 하나씩 직접 확인하고, 요즘 엔진에서는 같은 결과를 어떻게 얻는지 정리한다.
This notebook borrowed the Master System's numbers, but the real hardware works differently from today's computers at the most basic level. Back then, dedicated chips produced the picture and the sound directly; today general-purpose processors compute them. This chapter checks the differences one by one and shows how a modern engine gets the same results.
8.1숫자로 보는 차이The numbers
항목
세가 마스터 시스템 (1986)
요즘 PC·콘솔
CPU
Z80 3.58MHz, 8비트, 코어 1개
3~5GHz, 64비트, 코어 여러 개
메인 메모리
8 KB
16GB 안팎 (약 200만 배)
비디오 메모리
16 KB
수 GB
화면
256×192, 64색 중 16색씩 두 벌
1920×1080 이상, 픽셀마다 1,677만 색
그리는 방식
화면 칩(VDP)이 타일과 스프라이트를 주사선마다 합성. 프레임버퍼 없음
GPU가 프레임버퍼에 다 그린 뒤 한 번에 내보냄
스프라이트
64개, 한 줄에 8개, 뒤집기 없음
제한 없음 (한 번에 수만 개)
소리
PSG 칩: 사각파 3 + 노이즈 1, 모노
CPU가 샘플을 계산해 섞음. 48kHz 스테레오
화면 주기
60Hz (유럽 50Hz). 게임 코드가 여기에 묶임
60~240Hz, 가변 주사율
게임 용량
카드 32KB, 카트리지 최대 512KB
수 GB~100GB 이상
Item
Sega Master System (1986)
A PC or console today
CPU
Z80 at 3.58 MHz, 8-bit, one core
3–5 GHz, 64-bit, many cores
Main memory
8 KB
Around 16 GB (about 2 million times more)
Video memory
16 KB
Several GB
Screen
256×192, two palettes of 16 out of 64 colors
1920×1080 or more, 16.7 million colors per pixel
How it draws
The video chip (VDP) combines tiles and sprites line by line. No framebuffer
The GPU draws everything into a framebuffer, then sends it out
Sprites
64, eight per line, no flipping
No real limit (tens of thousands at once)
Sound
PSG chip: 3 square waves + 1 noise, mono
The CPU computes and mixes samples. 48 kHz stereo
Refresh
60 Hz (50 Hz in Europe). Game code is tied to it
60–240 Hz, variable refresh rate
Game size
32 KB on a card, up to 512 KB on a cartridge
Several GB to over 100 GB
오른쪽 열은 2020년대 중반의 일반적인 PC와 콘솔을 기준으로 한 대략값이다.
The right-hand column gives rough values for typical mid-2020s PCs and consoles.
8.2그래픽 ① 프레임버퍼가 없던 시절Graphics 1: no framebuffer
지금의 GPU는 화면 전체를 메모리(프레임버퍼)에 다 그려 놓은 다음 모니터로 보낸다. 마스터 시스템의 256×192 화면을 픽셀마다 4비트로 저장하면 24KB가 필요한데, 비디오 메모리는 16KB뿐이었다. 그래서 그림을 통째로 저장하지 않았다. 8×8 타일 그림을 최대 448장 넣어 두고, 32×28칸짜리 지도(네임 테이블)에 "이 칸에는 몇 번 타일"만 적는다. 화면 칩(VDP)은 TV의 전자빔이 지나가는 바로 그 순간에 그 줄의 픽셀을 지도와 타일에서 계산해 내보냈다.
2장과 3장에서 만난 제약이 모두 여기서 나온다. 스프라이트를 한 줄에 8개까지만 그리는 것도, 색을 번호로 적는 것도, 배경이 타일로 이루어진 것도 그렇다. 같은 타일을 여러 칸에 쓰면 메모리를 아끼고, 지도에 있는 비트 하나로 타일을 좌우나 상하로 뒤집어 쓸 수 있어서 뒤집힌 그림은 따로 저장하지 않았다. 반면 스프라이트에는 뒤집기 기능이 없었다. 보리처럼 왼쪽도 보는 캐릭터는 왼쪽 그림을 따로 저장해야 했다.
그러면 이 노트의 그림은 마스터 시스템에 들어갈까? 아래 계산기는 각 레이어를 8×8 조각으로 잘라 서로 다른 조각이 몇 장인지 센다.
A modern GPU draws the whole screen into memory, the framebuffer, and then sends it to the display. Storing the Master System's 256×192 screen at 4 bits per pixel would take 24 KB, and the console had only 16 KB of video memory. So the picture was never stored whole. Video memory held up to 448 tiles of 8×8 pixels and a 32×28 map, the name table, that only says "this cell shows tile number such-and-such." The video chip, the VDP, worked out each line's pixels from the map and the tiles at the very moment the TV's electron beam swept across that line.
All the limits from Chapters 2 and 3 come from this design: eight sprites per line, colors stored as numbers, backgrounds built from tiles. Reusing one tile in many cells saves memory, and a bit in the map flips a background tile horizontally or vertically, so mirrored copies never had to be stored. Sprites had no flipping, though. A character who also faces left, like Bori, needed separate left-facing pictures.
Would this notebook's graphics fit in a Master System? The calculator below cuts each layer into 8×8 pieces and counts how many different pieces there are.
하늘처럼 단순한 그림은 조각이 몇 장 안 나오지만, 구름과 산처럼 자유롭게 그린 그림은 조각이 거의 다 다르다. 그래서 그 시절 배경은 처음부터 타일을 재활용하도록 그렸다. 반복되는 무늬, 곧은 선, 똑같은 모양의 구름이 많은 이유다. 게다가 배경 레이어는 한 장뿐이라 이 노트처럼 레이어 넷을 겹칠 수도 없었다. 요즘은 이런 계산을 할 필요가 없다. 배경을 큰 그림 한 장으로 그려도 GPU 메모리에 넉넉히 들어간다.
Simple images like the sky produce only a few pieces, but freely drawn clouds and mountains make almost every piece different. That's why backgrounds of the era were drawn to reuse tiles from the start, with repeating patterns, straight edges, and lots of identical clouds. On top of that, there was only one background layer, so stacking four layers the way this notebook does wasn't possible either. Today none of this arithmetic is needed. A background painted as one large image fits easily in GPU memory.
8.3그래픽 ② 줄마다 스크롤 값을 바꾸는 요령Graphics 2: a new scroll value on every line
배경 레이어가 한 장뿐이니 3장처럼 레이어마다 다른 속도로 움직일 수 없다. 대신 VDP는 원하는 줄에 오면 CPU를 깨우는 라인 인터럽트를 제공했다. 전자빔이 산이 시작되는 줄에 오면 가로 스크롤 값을 바꾸고, 언덕이 시작되는 줄에서 또 바꾼다. 한 장짜리 배경이 띠마다 다른 속도로 움직이면서 패럴랙스처럼 보인다. 줄마다 조금씩 다른 값을 넣으면 물결이나 아지랑이도 만든다. 이것이 래스터 스크롤이다.
아래 화면은 배경 한 장을 줄 단위로 그린다. '주사선 보기'를 켜면 전자빔이 내려가는 속도를 늦춰서, 스크롤 값이 바뀌는 순간을 볼 수 있다.
With a single background layer you can't move layers at different speeds the way Chapter 3 does. What the VDP did offer was a line interrupt, which wakes the CPU when the beam reaches a chosen line. When the beam reaches the line where the mountains begin, the game changes the horizontal scroll value, and it changes it again where the hills begin. The single background then moves at a different speed in each band and reads as parallax. Put a slightly different value on every line and you get ripples or heat haze. This is raster scrolling.
The screen below draws one background line by line. Turn on "Show the beam" to slow the beam down and watch the moments when the scroll value changes.
요즘은 레이어마다 따로 그리고 3장처럼 카메라 이동량에 배율을 곱하면 끝이다. 물결 같은 효과는 셰이더로 만든다. 화면의 픽셀마다 한 번씩 실행되는 짧은 함수가 x 좌표에 sin(y)를 더한다.
Today you draw each layer separately and multiply the camera movement by a factor, as in Chapter 3. Effects like ripples are done in a shader: a short function that runs once for every pixel on screen and adds sin(y) to its x coordinate.
물결 셰이더 (GLSL)wave.frag
// 프래그먼트 셰이더: 화면의 픽셀마다 한 번씩 실행된다
uniform sampler2D scene; // 먼저 그려 둔 화면
uniform float time;
varying vec2 uv; // 0~1 사이의 화면 좌표
void main() {
float line = uv.y * 192.0; // 몇 번째 줄인가
float shift = sin(line * 0.3 + time) * 3.0 / 256.0; // 줄마다 다른 가로 이동
gl_FragColor = texture2D(scene, vec2(uv.x + shift, uv.y));
}
A ripple shader (GLSL)wave.frag
// Fragment shader: runs once for every pixel on the screen
uniform sampler2D scene; // the screen, already drawn
uniform float time;
varying vec2 uv; // screen position, 0 to 1
void main() {
float line = uv.y * 192.0; // which line this is
float shift = sin(line * 0.3 + time) * 3.0 / 256.0; // a different shift per line
gl_FragColor = texture2D(scene, vec2(uv.x + shift, uv.y));
}
8.4그래픽 ③ 작은 화면을 큰 모니터에Graphics 3: a small screen on a big display
256×192 화면을 1920×1080 모니터에 띄우려면 키워야 한다. 세로 기준으로 1080 ÷ 192 = 5.625배다. 이렇게 정수가 아닌 배율로 키우면 원래 픽셀 하나가 어떤 곳은 화면 픽셀 5개, 어떤 곳은 6개가 된다. 멈춰 있을 때는 잘 안 보이지만 스크롤하면 픽셀 폭이 들쑥날쑥 흔들린다. 부드러운 보간으로 키우면 흔들림은 없지만 그림이 뿌옇게 번진다.
그래서 레트로 스타일 게임은 보통 정수 배율로 키운다. 1080p라면 5배(1280×960)로 키우고 남는 공간은 검은 띠(레터박스)로 채운다. 색은 섞지 않고 가장 가까운 원래 픽셀을 그대로 쓴다(nearest neighbor). 아래에서 배율과 방식을 바꿔 가며 비교해 보자.
To show a 256×192 screen on a 1920×1080 monitor you have to enlarge it. Going by height, that's 1080 ÷ 192 = 5.625 times. At a non-integer scale, one original pixel becomes five screen pixels in some places and six in others. You barely notice on a still image, but when the picture scrolls, the uneven pixel widths shimmer. Smooth interpolation avoids the shimmer but blurs the picture.
So retro-style games usually scale by a whole number. On a 1080p display that's 5× (1280×960), with the leftover space filled by black bars, a letterbox. Colors aren't blended; each screen pixel takes the nearest original pixel (nearest neighbor). Compare scales and methods below.
하나 더 있다. 마스터 시스템의 픽셀은 TV에서 정사각형이 아니었다. 화면 칩이 점을 찍는 속도 때문에 픽셀이 가로로 조금 넓어서 가로:세로가 약 8:7이었다. 에뮬레이터와 이식판은 정사각 픽셀로 보여 줄지, 8:7로 늘려 그 시절 TV처럼 보여 줄지를 고른다. 이 노트는 정사각 픽셀로 그린다.
Unity. 카메라에 Pixel Perfect Camera 컴포넌트를 붙이고 기준 해상도를 256×192로 준다.
웹 캔버스. 캔버스를 원래 크기로 만들고 CSS에 image-rendering: pixelated를 준다. 표시 크기는 기기 픽셀 기준의 정수배로 계산한다. 이 페이지의 게임 화면은 레이아웃 편의상 본문 폭에 맞춰 늘리므로 정수 배율이 아니다. 9장의 '정수 배율' 스위치로 차이를 볼 수 있다.
One more detail: Master System pixels weren't square on a TV. Because of the rate at which the video chip put out pixels, each one was slightly wider than tall, about 8:7. Emulators and ports choose between square pixels and an 8:7 stretch that looks like the TVs of the time. This notebook draws square pixels.
In a modern engine it takes a few settings.
Godot 4. In Project Settings › Display › Window, set Stretch Mode to viewport and Scale Mode to integer (available from 4.3). Under Rendering › Textures, set Default Texture Filter to Nearest.
Unity. Add a Pixel Perfect Camera component to the camera and give it a reference resolution of 256×192.
Web canvas. Make the canvas its native size and give it image-rendering: pixelated in CSS. Compute the display size as a whole multiple in device pixels. For layout convenience, the game screens on this page stretch to the column width, so they aren't integer-scaled. The "Integer scale" switch in Chapter 9 shows the difference.
8.5소리 ① 칩이 소리를 만들던 시절Sound 1: when a chip made the sound
마스터 시스템의 CPU는 소리를 만들지 않았다. PSG 칩이 사각파와 노이즈를 직접 만들고, CPU는 1/60초마다 칩의 레지스터 몇 바이트(음높이와 볼륨)만 바꿨다. 그래서 음악 데이터는 악보만큼 작다. 대신 채널이 네 개뿐이라 5장에서 본 것처럼 효과음이 음악 채널을 빌려 써야 했다. 일본판 마스터 시스템에는 9채널 FM 음원 칩(YM2413)이 더 들어 있었지만, 미국판과 유럽판에는 PSG만 있었다.
지금의 컴퓨터에는 이런 음원 칩이 없다. 사운드 장치는 숫자(샘플)를 받아 스피커 전압으로 바꾸기만 하고, 소리는 CPU가 계산한다. 1초에 48,000개, 스테레오면 그 두 배의 숫자를 계산해 보낸다. 웹 오디오도 같은 방식이어서, 이 노트의 PSG도 진짜 칩이 아니라 브라우저가 계산한 파형이다.
The Master System's CPU didn't produce sound. The PSG chip generated the square waves and the noise itself, and every 1/60 of a second the CPU only changed a few bytes in the chip's registers: pitch and volume. That kept music data as small as sheet music. But there were only four channels, so sound effects had to borrow a music channel, as Chapter 5 showed. Japanese Master Systems added a nine-channel FM sound chip (the YM2413); American and European models had only the PSG.
Today's computers have no such sound chip. The audio device just turns numbers, called samples, into speaker voltages, and the CPU computes the sound: 48,000 numbers a second, twice that for stereo. Web Audio works the same way, so even this notebook's PSG isn't a real chip; it's a waveform computed by the browser.
8.6소리 ② 악보를 저장하던 시절, 녹음을 저장하는 지금Sound 2: storing the score, storing a recording
그 시절 게임은 음악을 악보로 저장했다. 지금 게임은 대부분 녹음된 파일(WAV, OGG 같은)로 저장한다. 같은 곡을 두 방식으로 저장하면 크기가 얼마나 다를까? 아래 버튼은 5장의 곡 전체를 같은 드라이버로 오프라인 렌더링해서 녹음 파일로 굽는다.
Games of that era stored music as a score. Most games today store it as recorded audio such as WAV or OGG files. How different are the sizes when the same song is stored both ways? The button below renders the whole song from Chapter 5 offline, with the same driver, and bakes it into a recording.
녹음 파일은 크지만 장점도 크다. 오케스트라든 사람 목소리든 무엇이든 담을 수 있고, 재생하는 데 계산이 거의 들지 않는다. 그래서 요즘은 칩튠 느낌의 음악도 트래커로 작곡한 뒤 WAV나 OGG로 내보내서 쓰는 경우가 많다. 무료 트래커인 Furnace는 마스터 시스템의 SN76489를 비롯한 여러 음원 칩을 흉내 내고, WAV나 VGM으로 내보낼 수 있다. 반대로 이 노트처럼 실행 중에 합성하면 데이터가 아주 작고, 게임 상황에 따라 템포나 악기를 바꾸기 쉽다.
Recordings are large, but their benefits are large too. They can hold anything, from an orchestra to a human voice, and playing them back costs almost no computation. So today even chiptune-style music is often composed in a tracker and exported to WAV or OGG. The free tracker Furnace imitates many sound chips, including the Master System's SN76489, and exports WAV or VGM. Synthesizing at run time, as this notebook does, keeps the data tiny and makes it easy to change tempo or instruments as the game goes on.
8.7소리 ③ 채널 빌리기 대신 믹싱과 덕킹Sound 3: mixing and ducking instead of borrowing channels
소리를 CPU가 계산하면 동시에 낼 수 있는 소리의 수는 계산 능력만큼 늘어난다. 효과음마다 새 목소리를 만들어 음악 위에 더하면 되니 음악 채널을 빌릴 필요가 없다. 대신 효과음이 묻히지 않도록, 효과음이 나는 동안 음악 볼륨을 잠깐 낮춘다. 이것을 덕킹이라고 한다. FMOD나 Wwise 같은 오디오 미들웨어와 게임 엔진의 오디오 믹서가 동시 목소리 수, 우선순위, 덕킹을 설정으로 제공한다.
아래에서 두 방식을 바꿔 가며 음악을 틀고 효과음 버튼을 빠르게 눌러 보자. 여기서 고른 방식은 이 페이지의 모든 게임에 적용된다.
When the CPU computes the sound, the number of sounds that can play at once grows with the processing power available. Each sound effect gets a new voice added on top of the music, so nothing has to borrow a music channel. To keep effects from getting buried, the music volume dips briefly while an effect plays. This is called ducking. Audio middleware such as FMOD and Wwise, and the mixers built into game engines, offer voice limits, priorities, and ducking as settings.
Switch between the two approaches below, start the music, and press the sound-effect buttons quickly. The approach you choose here applies to every game on this page.
8.8타이밍: 화면에 묶인 코드Timing: code tied to the screen
마스터 시스템의 게임은 TV가 화면 한 장을 그리는 1/60초에 맞춰 돌았다. 그 사이 Z80이 쓸 수 있는 시간은 약 6만 사이클이고 명령어 하나가 4~23사이클이니, 한 프레임에 실행할 수 있는 명령어는 수천 개 수준이다. 적이 많아져서 할 일이 이 예산을 넘으면 그 프레임을 제때 끝내지 못하고 다음 화면으로 넘어간다. 게임 전체가 느려지는 처리 지연(슬로우다운)이다. 유럽처럼 TV가 50Hz인 곳에서는 같은 게임이 따로 고치지 않으면 5/6 속도로 돌았다.
아래 두 화면은 같은 장면을 그린다. 위는 그 시절처럼 프레임마다 정해진 만큼 움직이는 게임이고, 아래는 1장의 고정 스텝 루프다. 적을 늘려 보자. 사이클 수는 설명을 위해 정한 예시 값이다.
Master System games ran in step with the 1/60 of a second the TV took to draw one picture. In that time the Z80 had about 60,000 cycles, and with each instruction taking 4 to 23 cycles, a frame had room for a few thousand instructions. When more enemies pushed the work past that budget, the frame couldn't finish in time and spilled into the next refresh. The whole game slowed down. In Europe, where TVs ran at 50 Hz, the same game ran at 5/6 speed unless someone adjusted it.
The two screens below show the same scene. The top one moves a fixed amount every frame, the way games did then; the bottom one uses the fixed-step loop from Chapter 1. Add enemies. The cycle counts are example values chosen for illustration.
지금의 CPU는 코어 하나만으로도 1/60초 동안 수천만 사이클을 쓴다. 대신 모니터가 60Hz, 120Hz, 144Hz, 가변 주사율까지 제각각이라 게임을 화면 주기에 묶을 수 없다. 그래서 1장의 고정 스텝 루프를 쓰고, 엔진들도 같은 구조를 기본으로 제공한다. Unity의 FixedUpdate는 기본 0.02초(50Hz)마다, Godot의 물리 처리는 기본 1초에 60번 돈다. 처리가 밀리면 옛날 게임은 느려졌지만, 고정 스텝 게임은 속도를 지키는 대신 그림을 건너뛴다.
A modern CPU has tens of millions of cycles per 1/60 of a second on a single core. Instead, displays now run at 60 Hz, 120 Hz, 144 Hz, or a variable refresh rate, so a game can't be tied to the refresh. That's why the fixed-step loop from Chapter 1 exists, and engines provide the same structure by default: Unity's FixedUpdate runs every 0.02 seconds (50 Hz), and Godot's physics runs 60 times a second. When work piles up, the old games slowed down; a fixed-step game keeps its speed and skips drawing frames instead.
8.9정리: 그때의 제약, 지금의 처리Summary: old limits, modern answers
그때의 제약
지금은 이렇게 한다
프레임버퍼 없음, 타일 448장
그림은 GPU 텍스처로 둔다. 타일맵은 아틀라스 한 장과 사각형 여러 개로 그려서, 드로 콜 한 번에 수천 칸을 그린다. Tiled나 LDtk 같은 레벨 에디터의 결과를 엔진의 타일맵이 바로 읽는다.
64색 중 16색 팔레트
픽셀마다 RGBA 색을 쓴다. 레트로 느낌을 원하면 팔레트 제한을 아트 규칙으로 지킨다. 팔레트 스왑은 색 번호를 담은 텍스처와 팔레트 텍스처를 셰이더에서 맞춰 보는 방식으로 한다.
한 줄에 스프라이트 8개, 뒤집기 없음
제한이 없다. 깜빡임은 연출로 일부러 넣을 때만 쓴다. 뒤집기는 그릴 때 배율을 -1로 주면 된다.
배경 한 장, 래스터 스크롤
레이어마다 그리고 패럴랙스 배율을 곱한다. 물결 같은 효과는 셰이더로 만든다.
256×192, 8:7 픽셀
낮은 해상도의 렌더 타깃에 그린 뒤 정수 배율로 키우고 레터박스를 둔다. 텍스처 필터는 nearest.
PSG 4채널, 채널 빌리기
소프트웨어 믹서가 목소리를 섞고, 효과음이 날 때 음악을 덕킹한다. 음악은 녹음 파일로 두고 FMOD나 Wwise 같은 미들웨어로 재생한다. 칩 느낌은 트래커로 작곡해서 낸다.
1/60초에 묶인 코드, 처리 지연
고정 스텝 + 보간. 처리가 밀리면 속도를 지키고 그림을 건너뛴다.
카트리지 수백 KB
용량보다 로딩 시간과 메모리 예산이 관건이다.
The old limit
What we do today
No framebuffer, 448 tiles
Keep the art in GPU textures. Draw a tile map as one atlas plus many quads, thousands of cells in a single draw call. Engine tile maps read the output of level editors such as Tiled or LDtk directly.
16 of 64 colors per palette
Use an RGBA color per pixel. For a retro look, keep a palette limit as an art rule. Do palette swaps in a shader that looks up a texture of color numbers in a palette texture.
Eight sprites per line, no flipping
No limit. Use flicker only as a deliberate effect. Flip by drawing with a scale of −1.
One background, raster scrolling
Draw each layer and multiply by a parallax factor. Make ripples and similar effects in a shader.
256×192, 8:7 pixels
Draw into a low-resolution render target, scale it by a whole number, and letterbox it. Nearest-neighbor texture filtering.
Four PSG channels, borrowed channels
A software mixer blends voices and ducks the music under sound effects. Music lives in recordings played through middleware such as FMOD or Wwise. For a chip sound, compose in a tracker.
Code tied to 1/60 s, slowdown
Fixed step plus interpolation. When work piles up, keep the speed and skip frames.
Cartridges of a few hundred KB
Loading time and memory budgets matter more than size.
Furnace · a free tracker that imitates many sound chips
devkitSMS · tools for writing new Master System games in C today
9장 · 완성
Chapter 9 · The finished game
보리 섬
Bori Island
지금까지 만든 부품을 모두 합친 게임이다. 4장에서 고친 그림과 레벨, 5장의 효과음과 음악, 6장의 물리 값, 8장에서 고른 믹싱 방식이 그대로 들어가 있다. 체력이 바닥나기 전에 열매를 먹으며 섬 끝의 깃발까지 가 보자.
This is the game with every part put together. The art and level you edited in Chapter 4, the sound effects and music from Chapter 5, the physics values from Chapter 6, and the mixing approach you picked in Chapter 8 are all in it. Eat fruit before your vitality runs out and reach the flag at the end of the island.
9.1여기서 더 만든다면Where to go next
스테이지 여러 개. 레벨 데이터를 배열로 늘리고 클리어 장면에서 다음 레벨을 불러온다. 레벨마다 배경 팔레트만 바꿔도 분위기가 크게 달라진다.
보스. 보스용 상태 기계를 하나 더 만든다. 공격 패턴 몇 개를 순서대로 돌리고, 맞을 때마다 하얗게 번쩍이게 한다.
경사면. 타일마다 높이 표를 붙이고, y축 충돌 처리에서 발밑 칸의 높이를 읽는다.
게임패드. 입력을 '행동'으로 바꿔 둔 덕분에 Gamepad API는 입력 모듈 한 곳에만 붙이면 된다.
리플레이. 고정 타임스텝이라 프레임마다의 입력만 기록하면 같은 플레이를 그대로 다시 돌릴 수 있다.
More stages. Turn the level data into an array and load the next level from the clear scene. Just changing the background palette per level changes the mood a lot.
A boss. Give it a state machine of its own. Cycle through a few attack patterns and flash it white on every hit.
Slopes. Attach a height table to each tile and read the height under the feet during the y-axis collision step.
Gamepads. Because input is already mapped to actions, the Gamepad API only needs to plug into the input module.
Replays. With a fixed timestep, recording each frame's input is enough to play the same run back exactly.
9.2소스 코드Source code
이 페이지의 전체 소스는 GitHub의 sean-lab/bori-island에 MIT 라이선스로 공개되어 있다. 라이브러리 없이 HTML, CSS, 자바스크립트만으로 만들었고, 빌드 스크립트가 파일들을 HTML 한 장으로 묶는다.
The full source of this page is on GitHub at sean-lab/bori-island under the MIT license. It's plain HTML, CSS, and JavaScript with no libraries, and a build script bundles the files into a single HTML page.