From 207febef9924c8fa5391185e75d42a7ea480bb38 Mon Sep 17 00:00:00 2001 From: Cliff Click Date: Mon, 10 Aug 2026 11:11:18 -0700 Subject: Chapter 31: Software Drawing, Time, and Graphical Games Co-authored-by: Hsiang-Ying Fu <66062782+fuhsnn@users.noreply.github.com> Co-authored-by: fuhsnn <66062782+fuhsnn@users.noreply.github.com> Co-authored-by: Symious <14933944+symious@users.noreply.github.com> Co-authored-by: Jun Kawasaki Co-authored-by: Claude Opus 5 --- docs/chapter31/examples/Breakout.smp | 364 +++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 docs/chapter31/examples/Breakout.smp (limited to 'docs/chapter31/examples/Breakout.smp') diff --git a/docs/chapter31/examples/Breakout.smp b/docs/chapter31/examples/Breakout.smp new file mode 100644 index 00000000..b1d0b443 --- /dev/null +++ b/docs/chapter31/examples/Breakout.smp @@ -0,0 +1,364 @@ +// Clear 24 x 8 bricks. Arrows or mouse move; Space/click serves; P pauses; +// R restarts; Q/Escape exits. Any argument runs ten frames as a smoke test. +u32 WIDTH = 1008, HEIGHT = 760, COLS = 24, ROWS = 8; +int LEFT = 24, RIGHT = 984, TOP = 84, BOTTOM = 718; +int BRICK_TOP = 152, BRICK_DX = 40, BRICK_DY = 22, BRICK_W = 36, BRICK_H = 18; +int RADIUS = 5, PADDLE_HALF = 56, PADDLE_Y = 674; +int READY = 0, PLAY = 1, WON = 2, LOST = 3; +int KEY_LEFT = 263, KEY_RIGHT = 262; // MiniFB key codes; gfx.LEFT is a mouse button. +i64 STEP_ms = 8, FRAME_ms = 16; +u32 BACKGROUND = 0x0b1220, WHITE = 0xeef7ff, MUTED = 0x8aabc4, CYAN = 0x4ce0ef; +val COLORS = new u32[~]{0xff5268,0xff8050,0xffb548,0xffdb58, + 0x9bdd58,0x45d8b0,0x49b9f5,0x8d94ff}; +val DIGITS = new u8[~][~]{"0","1","2","3","4","5","6","7","8","9"}; + +struct Game { + u8[] bricks = new u8[COLS*ROWS]{int i -> 1;}; + int score = 0, remaining = COLS*ROWS, lives = 3, depth = 1, phase = READY; + bool boosted = false, backWall = false, paused = false; + f64 x = 504.0, y = 663.0, dx = 0.4, dy = -0.916515138991168; + + // Fixed 8 ms simulation, split further to travel at most two pixels per step. + // Even at maximum boosted speed a ball cannot tunnel through an 18 px brick. + val _tick = { int direction -> + if( paused || phase == WON || phase == LOST ) return 0; + _paddle(paddle+direction*6.4); + if( phase == READY ) { x = paddle; y = PADDLE_Y-RADIUS-6; return 0; } + f64 seconds = 0.008; // 8ms + while( seconds > 0.000000001 && phase == PLAY ) { + f64 dt = 2.0/speed; + if( dt > seconds ) dt = seconds; + seconds -= dt; + f64 oldX = x, oldY = y; + x += dx*speed*dt; + y += dy*speed*dt; + if( x < LEFT+RADIUS && dx < 0.0 ) { + x = 2.0*(LEFT+RADIUS)-x; dx = -dx; _cue(1); + } + if( x > RIGHT-RADIUS && dx > 0.0 ) { + x = 2.0*(RIGHT-RADIUS)-x; dx = -dx; _cue(1); + } + if( y < TOP+RADIUS && dy < 0.0 ) { + y = 2.0*(TOP+RADIUS)-y; dy = -dy; + backWall = true; _cue(1); + } + if( dy > 0.0 && oldY <= PADDLE_Y-RADIUS && y >= PADDLE_Y-RADIUS && + paddle-PADDLE_HALF-RADIUS <= x <= paddle+PADDLE_HALF+RADIUS ) _rebound(); + if( y > BOTTOM+RADIUS ) { + _lostBall(); + break; + } + // Map the center into the grid of radius-expanded brick boxes. + // Their overlap is smaller than one cell: at most 2 x 2 candidates. + if( x <= LEFT+2 -RADIUS || x >= LEFT+2 +(COLS-1)*BRICK_DX+BRICK_W+RADIUS || + y <= BRICK_TOP-RADIUS || y >= BRICK_TOP+(ROWS-1)*BRICK_DY+BRICK_H+RADIUS ) + continue; + int col = (x-(LEFT+2 -RADIUS))/BRICK_DX; + int row = (y-(BRICK_TOP-RADIUS))/BRICK_DY; + bool hit = false; + for( int r = row > 0 ? row-1 : 0; r <= row && r < ROWS && !hit; r++ ) + for( int c = col > 0 ? col-1 : 0; c <= col && c < COLS; c++ ) { + int i = r*COLS+c; + if( bricks[i] == 0 ) continue; + f64 left = LEFT+2 +c*BRICK_DX-RADIUS; + f64 top = BRICK_TOP+r*BRICK_DY-RADIUS; + f64 right = left+BRICK_W+2*RADIUS; + f64 bottom= top +BRICK_H+2*RADIUS; + if( left < x < right && top < y < bottom ) { + bool back = false; + // Resolve the face crossed this step, using the previous center. + // Vertical faces win a simultaneous corner contact. + if( oldY <= top && dy > 0.0 ) { + y = top; dy = -dy; back = true; + } else if( oldY >= bottom && dy < 0.0 ) { + y = bottom; dy = -dy; + } else if( oldX <= left && dx > 0.0 ) { + x = left; dx = -dx; + } else if( oldX >= right && dx < 0.0 ) { + x = right; dx = -dx; + } else continue; // Missed all bricks + _brick(i,back); + hit = true; + break; + } + } + } + return 0; + }; + + + // Several sound things might happen in this event, pick one + int sound = 0; // Increasing priority: wall, paddle, brick, boost, miss, win. + val _cue = { int snd -> if( snd > sound ) sound = snd; }; + + // Ball speed is a fraction of deepest brick hit, plus backwall boost + f64 speed; + val _speed = { -> speed = (320.0+35.0*(depth-1))*(boosted ? 1.65 : 1.0); }; + + // Clamp paddle to wall sides + f64 paddle; + val _paddle = { f64 x -> + paddle = x < LEFT +PADDLE_HALF ? LEFT +PADDLE_HALF : + x > RIGHT-PADDLE_HALF ? RIGHT-PADDLE_HALF : + x; + }; + + // Hit the paddle + val _rebound = { -> + // Where along the paddle did you hit? + f64 offset = (x-paddle)/PADDLE_HALF; + if( offset < -1.0 ) offset = -1.0; + if( offset > 1.0 ) offset = 1.0; + dx = 0.88*offset; + // A near-center hit still drifts sideways instead of repeating forever. + if( -0.12 < dx < 0.12 ) dx = dx < 0.0 ? -0.12 : 0.12; + dy = -_sqrt(1.0-dx*dx); // Bounce upwards speed + y = PADDLE_Y-RADIUS; // Reset ball hit to top of paddle + backWall = false; // Did not hit back wall + _cue(2); // Paddle bounce sound + }; + + // Hit a brick? + int soundDepth; // Which brick depth for the boop + val _brick = { int index, bool back -> + if( bricks[index] == 0 ) return 0; // No brick to hit! + bricks[index] = 0; // Clear brick + int row = ROWS-index/COLS; // Get brick row + score += row; // Score by row + remaining--; // Fewer bricks to iwn + if( row > depth ) depth = row; // Max depth seen + // Brick-hit sound + if( sound <= 3 ) { _cue(3); soundDepth = row; } + if( back && backWall && !boosted ) { _cue(3); boosted = true; } + if( remaining == 0 ) { _cue(6); phase = WON; } + _speed(); + return 0; + }; + + // Missed ball with paddle + val _lostBall = { -> + lives--; + phase = lives == 0 ? LOST : READY; + backWall = false; + depth = 1; + boosted = false; + _speed(); + x = paddle; y = PADDLE_Y-RADIUS-6; + _cue(5); + }; + + // Reset for another round + val _reset = { -> + for( int i = 0; i < bricks#; i++ ) bricks[i] = 1; + score = 0; + remaining = COLS*ROWS; + lives = 3; + depth = 1; + phase = READY; + boosted = false; + backWall = false; + paused = false; + sound = 0; + soundDepth = 1; + paddle = 504.0; + x = paddle; + y = PADDLE_Y-RADIUS-6; + _speed(); + }; + + // -------------------------------- + // Drawing code + val _hud = { !sys.draw.Canvas c -> + c.text(24,22,"BREAKOUT",5,WHITE); + c.text(264,20,"SCORE",2,MUTED); + _number(c,264,40,score,3,4,WHITE); + c.text(424,20,"BRICKS",2,MUTED); + _number(c,424,40,remaining,3,4,WHITE); + c.text(584,20,"BALLS",2,MUTED); + for( int i = 0; i < lives; i++ ) c.fillCircle(590+23*i,50,5,CYAN); + c.text(714,20,"SPEED",2,MUTED); + int ispeed = speed; + _number(c,714,40,ispeed,3,4,boosted ? COLORS[0] : WHITE); + c.text(838,40,boosted ? "BOOST!" : "NORMAL",3,boosted ? COLORS[0] : MUTED); + }; + + + val _bricks = { !sys.draw.Canvas c -> + c.fillRect(LEFT-4,TOP-4,RIGHT-LEFT+8,4,MUTED); + c.fillRect(LEFT-4,TOP,4,BOTTOM-TOP,0x29435a); + c.fillRect(RIGHT,TOP,4,BOTTOM-TOP,0x29435a); + c.text(376,108,boosted ? "BACK WALL BOOST ACTIVE" : "BREAK THROUGH FOR A BOOST",2,boosted ? COLORS[0] : MUTED); + for( int i = 0; i < bricks#; i++ ) { + if( bricks[i] == 0 ) continue; + int row = i/COLS; + i64 x = LEFT+2+(i-row*COLS)*BRICK_DX, y = BRICK_TOP+row*BRICK_DY; + c.fillRect(x,y,BRICK_W,BRICK_H,COLORS[row]); + c.fillRect(x,y,BRICK_W,2,WHITE); + } + }; + + val _overlay = { !sys.draw.Canvas c -> + if( phase != PLAY || paused ) { + c.fillRect(246,422,516,108,0x17263a).rect(246,422,516,108,2,0x29435a); + u8[~] title = paused ? "PAUSED" : phase == WON ? "WALL CLEARED!" : phase == LOST ? "GAME OVER" : "READY?"; + c.text(504-(title#*4-1)*4/2,442,title,4,WHITE); + u8[~] help = paused ? "P TO CONTINUE" : phase == WON || phase == LOST ? "R TO PLAY AGAIN" : "SPACE OR CLICK TO SERVE"; + c.text(504-(help#*4-1)*2/2,490,help,2,CYAN); + } + return 0; + }; + + // With this Canvas, we draw into the blank canvas, then "present". The + // underlying graphics lib copies the bits, and we are then free to clear + // and draw again while the old bits continue to be presented. + val _draw = { !sys.draw.Canvas c -> + c.clear(BACKGROUND); + _hud(c); + _bricks(c); + i64 paddle = paddle, ballX = x, ballY = y; + int glowRadius = RADIUS+3; + c.fillRect(paddle-PADDLE_HALF,PADDLE_Y,2*PADDLE_HALF,12,CYAN); + c.fillRect(paddle-PADDLE_HALF+4,PADDLE_Y+2,2*PADDLE_HALF-8,2,WHITE); + c.fillCircle(ballX,ballY,glowRadius,boosted ? 0x672a42 : 0x224b61); + c.fillCircle(ballX,ballY,RADIUS,boosted ? COLORS[0] : WHITE); + _overlay(c); + c.text(24,738,"ARROWS / MOUSE: MOVE SPACE / CLICK: SERVE P: PAUSE R: RESTART Q / ESC: QUIT",2,MUTED); + }; + + // Served and still holding serve key + bool serveHeld; + // Paused and still holding pause key + bool pauseHeld; + // Restart and still holding restart key + bool restartHeld; + // Last mouse position; track moving mouse + int lastMouse; + + // Read and act on the controls + val _input = { -> + val gfx = sys.gfx; + bool serve = gfx.key(' ') || gfx.button(gfx.LEFT); + bool pause = gfx.key('P'); + bool restart= gfx.key('R'); + + // Keyboard direction and mouse position + int direction = (gfx.key(KEY_RIGHT) ? 1 : 0)-(gfx.key(KEY_LEFT) ? 1 : 0); + int mouse = gfx.mouseX(); + + // Restart requested + restartHeld = debounce( restart, restartHeld, { -> _reset(); sys.audio.stop(); } ); + + // Playing or ready to play game? + if( phase == READY || phase == PLAY ) { + // Flip pause state + pauseHeld = debounce( pause, pauseHeld, { -> paused = !paused; } ); + + // If no key and mouse moved, set paddle to mouse + if( mouse != lastMouse && direction == 0 && !paused ) { + _paddle(mouse*1.0); // Set paddle to mouse + // If READY and not PLAY, ball tracks paddle directly + if( phase == READY ) x = paddle; + } + lastMouse = mouse; + + } + + // Serve requested + serveHeld = debounce( serve, serveHeld, _serve ); + + // Return key direction, so the tick tracking can slide paddle smoothly + return direction; + }; + + val debounce = { bool b, bool bHeld, { int } action -> + if( b && !bHeld ) // Action requested and repeating + action(); // Do action + b; // Dont action again, until 'b' is released + }; + + + // Serve the ball; give it a nominal direction and speed + val _serve = { -> + if( phase == READY && !paused ) { + phase = PLAY; + dx = 0.4; + dy = -_sqrt(1.0-dx*dx); + } + return 0; + }; +}; + +// Fixed-width decimal labels avoid allocating new strings on every frame. +val _number = { !sys.draw.Canvas c, i64 x, i64 y, int value, int digits, int scale, u32 color -> + for( int i = 0; i < digits; i++ ) { + c.text(x+(digits-1-i)*4*scale,y,DIGITS[value-value/10*10],scale,color); + value /= 10; + } +}; + +// Short, fading triangle waves; the array initializer builds PCM once at startup. +val _tone = { int period -> + val samples = new i16[~2400]{int i -> + int phase = i-i/period*period; + int triangle = phase < period/2 ? phase : period-phase; + return (triangle*4-period)*6000/period*(2400-i)/2400; + }; + int handle = sys.audio.load(samples); + return handle < 0 ? sys.audio.BOOP : handle; +}; + + +// Fast low accuracy sqrt +val _sqrt = { f64 n -> + f64 r = 1.0; + for( int i = 0; i < 8; i++ ) r = (r+n/r)*0.5; + return r; +}; + +val main = { u8[~][] args -> + // Shortcuts + val gfx = sys.gfx, audio = sys.audio; + + // Open a window + if( gfx.open("Simple Breakout",WIDTH,HEIGHT) != gfx.OK ) return 1; + + // Audio setup; pre-specify a series of sounds + audio.open(); // Optional: a missing audio backend leaves play silent. + val sounds = new int[~]{0,_tone(120),_tone(80),audio.BOOP,audio.WIN,audio.LOSE,audio.WIN}; + // Bottom to top: roughly 333..800 Hz, below the old 1000 Hz brick tone. + val brickSounds = new int[~ROWS]{int i -> int period = 144-i*12; _tone(period);}; + + // Setup drawing board + val c = new sys.draw.Canvas(WIDTH,HEIGHT); + + // Setup game contents + val g = new Game; + g._reset(); + + // + int frames = 0, status = gfx.OK; + i64 last_ms = sys.time.monotonic(), pending_ms = 0, frame_ms = last_ms; + + while( status == gfx.OK ) { + if( gfx.key('Q') || gfx.key(gfx.ESCAPE) ) break; + int direction = g._input(); + i64 now_ms = sys.time.monotonic(), elapsed_ms = now_ms-last_ms; + last_ms = now_ms; + // Drop long stalls instead of teleporting the ball after a window drag. + pending_ms += elapsed_ms < 0 ? 0 : elapsed_ms > 64 ? 64 : elapsed_ms; + while( pending_ms >= STEP_ms ) { g._tick(direction); pending_ms -= STEP_ms; } + if( g.sound != 0 ) { + audio.play(g.sound == 3 ? brickSounds[g.soundDepth-1] : sounds[g.sound]); + g.sound = 0; + } + if( now_ms >= frame_ms ) { + g._draw(c); + status = gfx.present(c.pixels); + frame_ms = now_ms+FRAME_ms; + if( args# > 1 && ++frames == 10 ) break; + } + if( status == gfx.OK ) status = gfx.wait(STEP_ms-pending_ms); + } + audio.close(); gfx.close(); + return status == gfx.ERROR ? 1 : 0; +}; -- cgit v1.2.3