aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-08 00:38:55 +0100
committerGene Pasquet <dev@etenil.net>2026-04-08 00:38:55 +0100
commit0c3a700aa94a0256c5e5b1a14819f10b3d3e869b (patch)
treec1b0dc233769bea9f6a545333687539ace5b3804
parentf8cc4a748bb8b6431a1023a876745b1bb473eb19 (diff)
Support scling
-rw-r--r--Makefile2
-rw-r--r--demo/scaling.scm77
-rw-r--r--docs/api.org45
-rw-r--r--docs/guide.org27
-rw-r--r--engine.scm23
-rw-r--r--tests/engine-test.scm30
6 files changed, 193 insertions, 11 deletions
diff --git a/Makefile b/Makefile
index b8b6a94..990d589 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@
MODULE_NAMES := entity tween tilemap world input physics renderer assets engine mixer sound animation ai prefabs scene-loader
OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES))
-DEMO_NAMES := platformer shmup topdown audio sandbox spritefont menu tweens
+DEMO_NAMES := platformer shmup topdown audio sandbox spritefont menu tweens scaling
DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES))
UNIT_NAMES := $(patsubst %,downstroke-%,$(MODULE_NAMES))
diff --git a/demo/scaling.scm b/demo/scaling.scm
new file mode 100644
index 0000000..983e583
--- /dev/null
+++ b/demo/scaling.scm
@@ -0,0 +1,77 @@
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ downstroke-engine
+ downstroke-world
+ downstroke-entity
+ downstroke-input
+ downstroke-renderer
+ downstroke-assets)
+
+;; Logical resolution: 320×240, displayed at 640×480 via scale: 2
+
+(define +width+ 320)
+(define +height+ 240)
+(define +box-size+ 16)
+(define +speed+ 2)
+
+(define *game*
+ (make-game
+ title: "Demo: Scaling (2×)"
+ width: +width+ height: +height+
+ scale: 2
+
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-scene
+ entities: (list (list #:type 'box
+ #:x (/ +width+ 2)
+ #:y (/ +height+ 2)
+ #:width +box-size+
+ #:height +box-size+
+ #:vx 0 #:vy 0
+ #:color '(255 200 0)))
+ tilemap: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f
+ camera-target: #f
+ background: '(30 30 50))))
+
+ update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (box (car (scene-entities scene)))
+ (vx (cond ((input-held? input 'left) (- +speed+))
+ ((input-held? input 'right) +speed+)
+ (else 0)))
+ (vy (cond ((input-held? input 'up) (- +speed+))
+ ((input-held? input 'down) +speed+)
+ (else 0)))
+ (nx (max 0 (min (- +width+ +box-size+)
+ (+ (entity-ref box #:x 0) vx))))
+ (ny (max 0 (min (- +height+ +box-size+)
+ (+ (entity-ref box #:y 0) vy))))
+ (box (entity-set (entity-set box #:x nx) #:y ny)))
+ (scene-entities-set! scene (list box))))
+
+ render: (lambda (game)
+ (let* ((renderer (game-renderer game))
+ (scene (game-scene game))
+ (box (car (scene-entities scene)))
+ (bx (inexact->exact (floor (entity-ref box #:x 0))))
+ (by (inexact->exact (floor (entity-ref box #:y 0)))))
+ ;; Draw the colored box
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color 255 200 0))
+ (sdl2:render-fill-rect! renderer
+ (sdl2:make-rect bx by +box-size+ +box-size+))
+ ;; Draw a border around the logical viewport
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color 100 100 100))
+ (sdl2:render-draw-rect! renderer
+ (sdl2:make-rect 0 0 +width+ +height+))
+ ;; Draw crosshair at center
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color 60 60 80))
+ (sdl2:render-draw-line! renderer (/ +width+ 2) 0 (/ +width+ 2) +height+)
+ (sdl2:render-draw-line! renderer 0 (/ +height+ 2) +width+ (/ +height+ 2))))))
+
+(game-run! *game*)
diff --git a/docs/api.org b/docs/api.org
index 45593e4..c418ec8 100644
--- a/docs/api.org
+++ b/docs/api.org
@@ -17,6 +17,7 @@ The engine module provides the top-level game lifecycle and state management.
(title "Downstroke Game")
(width 640)
(height 480)
+ (scale 1)
(frame-delay 16)
(input-config *default-input-config*)
(preload #f)
@@ -31,8 +32,9 @@ Creates and initializes a game object. All parameters are optional keywords.
| Parameter | Type | Default | Description |
|-----------+------+---------+-------------|
| ~title~ | string | "Downstroke Game" | Window title |
-| ~width~ | integer | 640 | Game window width in pixels |
-| ~height~ | integer | 480 | Game window height in pixels |
+| ~width~ | integer | 640 | Logical game width in pixels |
+| ~height~ | integer | 480 | Logical game height in pixels |
+| ~scale~ | positive integer | 1 | Whole-game pixel scaling factor (window = width×scale by height×scale) |
| ~frame-delay~ | integer | 16 | Delay between frames in milliseconds (30 FPS ≈ 33) |
| ~input-config~ | input-config | *default-input-config* | Keyboard/controller mappings |
| ~preload~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called once before create |
@@ -43,13 +45,50 @@ Creates and initializes a game object. All parameters are optional keywords.
The game object is the central hub. Use it to store/retrieve assets, manage scenes, and access the current input state.
+*** Scaling
+
+The ~scale:~ parameter controls integer pixel scaling for the entire game. When ~scale:~ is greater than 1, the OS window is created at ~width×scale~ by ~height×scale~ pixels, but SDL2's logical renderer size is set to ~width~ by ~height~. This means all game code (rendering, physics, coordinates) works in the logical resolution — SDL2 handles the upscaling automatically.
+
+This affects everything uniformly: tiles, sprites, text, colored rectangles, and debug overlays. Mouse/touch input coordinates are also automatically mapped back to the logical resolution.
+
+#+begin_src scheme
+;; 320×240 game rendered in a 640×480 window (2× pixel scaling)
+(make-game title: "Pixel Art Game" width: 320 height: 240 scale: 2)
+
+;; 256×224 NES-style with 3× scaling → 768×672 window
+(make-game title: "Retro Game" width: 256 height: 224 scale: 3)
+#+end_src
+
+Only positive integers are accepted; fractional or zero values signal an error.
+
+*** Fullscreen
+
+Downstroke does not provide a built-in ~fullscreen:~ keyword, but you can make any game fullscreen by setting the SDL2 window flag after the game starts. Use the ~preload:~ hook (the window exists by then):
+
+#+begin_src scheme
+(make-game
+ title: "My Game" width: 320 height: 240 scale: 2
+ preload: (lambda (game)
+ ;; 'fullscreen-desktop scales to fill the screen without changing resolution
+ (sdl2:window-fullscreen-set! (game-window game) 'fullscreen-desktop)))
+#+end_src
+
+The two fullscreen modes are:
+
+| Mode | SDL2 symbol | Behavior |
+|------+-------------+-----------|
+| Desktop fullscreen | ~'fullscreen-desktop~ | Fills the screen at the desktop resolution; SDL2 handles scaling and letterboxing. Best for most games. |
+| Exclusive fullscreen | ~'fullscreen~ | Changes the display resolution to match ~width×scale~ by ~height×scale~. Use only when you need exclusive display control. |
+
+Combine with ~scale:~ to get pixel-perfect fullscreen: set your logical resolution small (e.g. 320×240), use ~scale:~ for the default windowed size, and let ~'fullscreen-desktop~ handle the rest.
+
** ~game-run!~
#+begin_src scheme
(game-run! game)
#+end_src
-Starts the main event loop. Initializes SDL2, opens the window, and runs the frame loop indefinitely until the user quits or the ~quit~ action is pressed. Never returns.
+Starts the main event loop. Initializes SDL2, opens the window (at ~width×scale~ by ~height×scale~ pixels), sets the logical render size when ~scale~ > 1, and runs the frame loop indefinitely until the user quits or the ~quit~ action is pressed. Never returns.
Lifecycle order within each frame:
1. Collect SDL2 events
diff --git a/docs/guide.org b/docs/guide.org
index 380f04b..1c5a8f3 100644
--- a/docs/guide.org
+++ b/docs/guide.org
@@ -216,6 +216,32 @@ See =demo/platformer.scm= in the engine source for a complete working example.
* Development Tips
+** Pixel Scaling
+
+Retro-style games often use a small logical resolution (e.g. 320×240) but need a larger window so players can actually see things. Use ~scale:~ to scale everything uniformly:
+
+#+begin_src scheme
+(make-game title: "Pixel Art" width: 320 height: 240 scale: 2)
+;; Creates a 640×480 window; all coordinates remain in 320×240 space
+#+end_src
+
+This is integer-only scaling. All rendering — tiles, sprites, text, debug overlays — is scaled automatically by SDL2. Game logic and coordinates are unaffected.
+
+See =demo/scaling.scm= for a complete example.
+
+** Fullscreen
+
+Use SDL2 window flags in the ~preload:~ hook to go fullscreen:
+
+#+begin_src scheme
+(make-game
+ title: "Fullscreen Game" width: 320 height: 240 scale: 2
+ preload: (lambda (game)
+ (sdl2:window-fullscreen-set! (game-window game) 'fullscreen-desktop)))
+#+end_src
+
+~'fullscreen-desktop~ fills the screen without changing display resolution; SDL2 handles letterboxing and scaling. ~'fullscreen~ uses exclusive fullscreen at the window size.
+
** Debug Mode
During development, enable ~debug?: #t~ on ~make-game~ to visualize collision boxes and the tile grid. This helps you understand what the physics engine "sees" and debug collision problems:
@@ -252,6 +278,7 @@ Downstroke includes several demo games that showcase different features:
| Sprite Font | =demo/spritefont.scm= | Bitmap text rendering using non-contiguous tileset ranges |
| Menu | =demo/menu.scm= | State machine menus, keyboard navigation, TTF text rendering |
| Tweens | =demo/tweens.scm= | Easing curves, =tween-step=, =#:skip-pipelines= with tile collision |
+| Scaling | =demo/scaling.scm= | Integer pixel scaling with =scale: 2=, 2× upscaled rendering |
Each demo is self-contained and serves as a working reference for a particular game mechanic.
diff --git a/engine.scm b/engine.scm
index 38ebfed..d88a94b 100644
--- a/engine.scm
+++ b/engine.scm
@@ -18,6 +18,7 @@
(defstruct game
title width height
+ scale ;; positive integer: whole-game pixel scaling factor
window renderer
input ;; input-state record
input-config ;; input-config record
@@ -41,14 +42,18 @@
(define (make-game #!key
(title "Downstroke Game")
(width 640) (height 480)
+ (scale 1)
(frame-delay 16)
(input-config *default-input-config*)
(preload #f) (create #f) (update #f) (render #f)
(debug? #f))
+ (unless (and (integer? scale) (positive? scale))
+ (error "make-game: scale must be a positive integer" scale))
(make-game*
title: title
width: width
height: height
+ scale: scale
window: #f
renderer: #f
scene: #f
@@ -128,12 +133,18 @@
(sdl2:game-controller-open! i))
(init-controllers (+ i 1))))
- ;; 2. Create window + renderer
- (game-window-set! game
- (sdl2:create-window! (game-title game) 'centered 'centered
- (game-width game) (game-height game) '()))
- (game-renderer-set! game
- (sdl2:create-renderer! (game-window game) -1 '(accelerated)))
+ ;; 2. Create window + renderer (window size = logical size × scale)
+ (let ((scale (game-scale game)))
+ (game-window-set! game
+ (sdl2:create-window! (game-title game) 'centered 'centered
+ (* (game-width game) scale)
+ (* (game-height game) scale) '()))
+ (game-renderer-set! game
+ (sdl2:create-renderer! (game-window game) -1 '(accelerated)))
+ (when (> scale 1)
+ (sdl2:render-logical-size-set!
+ (game-renderer game)
+ (list (game-width game) (game-height game)))))
;; 3. preload: hook — user loads assets here
(when (game-preload-hook game)
diff --git a/tests/engine-test.scm b/tests/engine-test.scm
index f886165..9290ad7 100644
--- a/tests/engine-test.scm
+++ b/tests/engine-test.scm
@@ -18,6 +18,7 @@
(define (game-controller-open! i) #f)
(define (create-window! . args) 'mock-window)
(define (create-renderer! . args) 'mock-renderer)
+ (define (render-logical-size-set! . args) #f)
(define (destroy-window! . args) #f)
(define (make-color r g b #!optional (a 255)) (list r g b a))
(define render-draw-color (getter-with-setter (lambda (r) #f) (lambda (r c) #f)))
@@ -147,7 +148,10 @@
(game-input g))
(test-equal "debug? defaults to #f"
#f
- (game-debug? g))))
+ (game-debug? g))
+ (test-equal "scale defaults to 1"
+ 1
+ (game-scale g))))
(test-group "make-game with keyword args"
(let ((g (make-game title: "My Game" width: 320 height: 240 frame-delay: 33)))
@@ -164,6 +168,30 @@
#t
(game-debug? (make-game debug?: #t))))
+(test-group "make-game scale keyword"
+ (test-equal "scale defaults to 1"
+ 1
+ (game-scale (make-game)))
+ (test-equal "scale can be set to 2"
+ 2
+ (game-scale (make-game scale: 2)))
+ (test-equal "scale can be set to 3"
+ 3
+ (game-scale (make-game scale: 3)))
+ (import (chicken condition))
+ (let ((caught #f))
+ (condition-case (make-game scale: 0)
+ (e (exn) (set! caught #t)))
+ (test-assert "scale: 0 signals error" caught))
+ (let ((caught #f))
+ (condition-case (make-game scale: -1)
+ (e (exn) (set! caught #t)))
+ (test-assert "scale: -1 signals error" caught))
+ (let ((caught #f))
+ (condition-case (make-game scale: 1.5)
+ (e (exn) (set! caught #t)))
+ (test-assert "scale: 1.5 signals error" caught)))
+
(test-group "game-asset and game-asset-set!"
(let ((g (make-game)))
(test-equal "missing key returns #f"