diff options
-rw-r--r-- | Makefile.am | 2 | ||||
-rw-r--r-- | chickadee/math/grid.scm | 404 | ||||
-rw-r--r-- | examples/grid.scm | 87 |
3 files changed, 493 insertions, 0 deletions
diff --git a/Makefile.am b/Makefile.am index 4640581..1ae2d24 100644 --- a/Makefile.am +++ b/Makefile.am @@ -51,6 +51,7 @@ SOURCES = \ chickadee/math/matrix.scm \ chickadee/math/quaternion.scm \ chickadee/math/rect.scm \ + chickadee/math/grid.scm \ chickadee/math/easings.scm \ chickadee/render/color.scm \ chickadee/render/gl.scm \ @@ -83,6 +84,7 @@ EXTRA_DIST += \ examples/text.scm \ examples/nine-patch.scm \ examples/tiled.scm \ + examples/grid.scm \ examples/images/AUTHORS \ examples/images/chickadee.png \ examples/images/dialog-box.png \ diff --git a/chickadee/math/grid.scm b/chickadee/math/grid.scm new file mode 100644 index 0000000..0361333 --- /dev/null +++ b/chickadee/math/grid.scm @@ -0,0 +1,404 @@ +;;; Chickadee Game Toolkit +;;; Copyright © 2018 David Thompson <davet@gnu.org> +;;; +;;; Chickadee is free software: you can redistribute it and/or modify +;;; it under the terms of the GNU General Public License as published +;;; by the Free Software Foundation, either version 3 of the License, +;;; or (at your option) any later version. +;;; +;;; Chickadee is distributed in the hope that it will be useful, but +;;; WITHOUT ANY WARRANTY; without even the implied warranty of +;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +;;; General Public License for more details. +;;; +;;; You should have received a copy of the GNU General Public License +;;; along with this program. If not, see +;;; <http://www.gnu.org/licenses/>. + +;;; Commentary: +;; +;; Axis-aligned bounding box collision grid. +;; +;; Lots of inspiration drawn from https://github.com/kikito/bump.lua +;; +;;; Code: + +(define-module (chickadee math grid) + #:use-module (chickadee array-list) + #:use-module (chickadee math rect) + #:use-module (chickadee math vector) + #:use-module (ice-9 match) + #:use-module (srfi srfi-9) + #:use-module (srfi srfi-11) + #:export (slide + cell? + cell-count + make-grid + grid? + grid-cell-size + grid-add + grid-remove + grid-move + grid-clear + grid-cell-count + grid-item-count + for-each-cell + for-each-item)) + + +;;; +;;; Collision resolvers +;;; + +(define (slide item item-rect other other-rect goal) + "Resolve the collision that occurs between ITEM and OTHER when +moving ITEM-RECT to GOAL by sliding ITEM-RECT the minimum amount +needed to make the rectangles no longer overlap." + (let* ((goal-x (vec2-x goal)) + (goal-y (vec2-y goal)) + (x1 (max goal-x (rect-left other-rect))) + (x2 (min (+ goal-x (rect-width item-rect)) (rect-right other-rect))) + (y1 (max goal-y (rect-bottom other-rect))) + (y2 (min (+ goal-y (rect-height item-rect)) (rect-top other-rect))) + (x-fix (- x2 x1)) + (y-fix (- y2 y1))) + (if (< x-fix y-fix) + (if (= goal-x x1) + (set-vec2-x! goal (+ (vec2-x goal) x-fix)) + (set-vec2-x! goal (+ (vec2-x goal) (- x-fix)))) + (if (= goal-y y1) + (set-vec2-y! goal (+ (vec2-y goal) y-fix)) + (set-vec2-y! goal (+ (vec2-y goal) (- y-fix))))))) + + +;;; +;;; Cells +;;; + +(define-record-type <cell> + (make-cell items count) + cell? + (items cell-items) + (count cell-count set-cell-count!)) + +(define (make-empty-cell) + (make-cell (make-hash-table) 0)) + +(define (cell-add cell item) + (hashq-set! (cell-items cell) item #t) + (set-cell-count! cell (+ (cell-count cell) 1))) + +(define (cell-remove cell item) + (hashq-remove! (cell-items cell) item) + (set-cell-count! cell (- (cell-count cell) 1))) + +(define (for-each-cell-item proc cell) + "Call PROC with each item in CELL." + (hash-for-each (lambda (item unused) + (proc item)) + (cell-items cell))) + + +;;; +;;; Grid +;;; + +(define-record-type <grid> + (%make-grid cell-size rects rows scratch-rect buffer visited) + grid? + (cell-size grid-cell-size) + (rects grid-rects) + (rows grid-rows) + ;; The below fields are scratch space data structures that are + ;; allocated once when the grid is created to cut down on + ;; allocations while modifying the grid and checking for collisions. + (scratch-rect grid-scratch-rect) + (buffer grid-buffer) + (visited grid-visited)) + +(define* (make-grid #:optional (cell-size 64)) + "Create new grid partitioned by CELL-SIZE." + (%make-grid cell-size + (make-hash-table) + (make-hash-table) + (make-rect 0.0 0.0 0.0 0.0) + (make-array-list) + (make-hash-table))) + +(define (item-in-grid? grid item) + "Return #t if ITEM is in GRID." + (hashq-ref (grid-rects grid) item)) + +(define (grid-row-ref grid y) + "Return the row at index Y in GRID." + (let ((rows (grid-rows grid))) + (or (hashq-ref rows y) + (let ((new-row (make-hash-table))) + (hashq-set! rows y new-row) + new-row)))) + +(define (row-column-ref row x) + "Return the cell at index X in ROW." + (or (hashq-ref row x) + (let ((new-cell (make-empty-cell))) + (hashq-set! row x new-cell) + new-cell))) + +(define (grid-cell-ref grid x y) + "Return the cell in GRID at (X, Y)." + (row-column-ref (grid-row-ref grid y) x)) + +(define (grid-rect-ref grid item) + "Return the rect for ITEM in GRID." + (hashq-ref (grid-rects grid) item)) + +(define (grid-cell-bounds grid rect) + "Return the range of cells that RECT occupies in GRID. The first +two return values are the min/max x coordinate, the last two are the +min/max y coordinate." + (define (to-cell n) + (inexact->exact (floor (/ n (grid-cell-size grid))))) + (values (to-cell (rect-left rect)) + (to-cell (rect-right rect)) + (to-cell (rect-bottom rect)) + (to-cell (rect-top rect)))) + +(define (for-each-coord proc minx maxx miny maxy) + "Call PROC with each (X, Y) coordinate pair formed by the inclusive +ranges [MINX, MAXX] and [MINY, MAXY]." + (let yloop ((y miny)) + (when (<= y maxy) + (let xloop ((x minx)) + (when (<= x maxx) + (proc x y) + (xloop (+ x 1)))) + (yloop (+ y 1))))) + +(define* (for-each-cell proc grid #:optional rect) + "Call PROC with each cell in GRID that intersects RECT, or every +cell if RECT is #f." + (if rect + (let-values (((minx maxx miny maxy) (grid-cell-bounds grid rect))) + (for-each-coord (lambda (x y) + (proc (grid-cell-ref grid x y) x y)) + minx maxx miny maxy)) + (hash-for-each (lambda (y row) + (hash-for-each (lambda (x cell) + (proc cell x y)) + row)) + (grid-rows grid)))) + +(define (for-each-item-in-area proc grid rect) + "Call PROC with each item in GRID that overlaps the area defined by +RECT." + (for-each-cell (lambda (cell x y) + (for-each-cell-item (lambda (item) + (proc item (grid-rect-ref grid item))) + cell)) + grid rect)) + +(define (for-each-item proc grid) + "Call PROC for each item in GRID." + (hash-for-each proc (grid-rects grid))) + +(define (grid-add grid item x y width height) + "Add ITEM to GRID represented by axis-aligned bounding box defined +by X, Y, WIDTH, HEIGHT." + (when (item-in-grid? grid item) + (error "item already in grid" item)) + (let ((rect (make-rect x y width height))) + (hashq-set! (grid-rects grid) item rect) + (for-each-cell (lambda (cell x y) + (cell-add cell item)) + grid rect))) + +(define (grid-remove grid item) + "Remove ITEM from GRID." + (let ((rect (grid-rect-ref grid item))) + (unless rect + (error "item not in grid" item)) + (hashq-remove! (grid-rects grid) item) + (for-each-cell (lambda (cell x y) + (cell-remove cell item)) + grid rect))) + +(define (grid-update grid item position) + "Move ITEM in GRID to POSITION." + (let* ((rect (grid-rect-ref grid item)) + (moved-rect (grid-scratch-rect grid))) + (set-rect-x! moved-rect (vec2-x position)) + (set-rect-y! moved-rect (vec2-y position)) + (set-rect-width! moved-rect (rect-width rect)) + (set-rect-height! moved-rect (rect-height rect)) + (let-values (((minx1 maxx1 miny1 maxy1) + (grid-cell-bounds grid rect)) + ((minx2 maxx2 miny2 maxy2) + (grid-cell-bounds grid moved-rect))) + (set-rect-x! rect (vec2-x position)) + (set-rect-y! rect (vec2-y position)) + (for-each-coord (lambda (x y) + (when (or (< x minx2) (> x maxx2) + (< y miny2) (> y maxy2)) + (cell-remove (grid-cell-ref grid x y) item))) + minx1 maxx1 miny1 maxy1) + (for-each-coord (lambda (x y) + (let ((cell (grid-cell-ref grid x y))) + (when (or (< x minx1) (> x maxx1) + (< y miny1) (> y maxy1)) + (cell-add cell item)))) + minx2 maxx2 miny2 maxy2)))) + +(define (grid-find-collision grid item goal filter) + "Return the object that ITEM in GRID collides with while trying to +move to GOAL. + +FILTER is a procedure that determines how such a +collision should be resolved between two objects. It is called +like (FILTER ITEM OTHER), where OTHER is an object in close promimity +to other. If ITEM and OTHER should not collide with each other, +FILTER must return #f. Otherwise, FILTER returns a collision +resolution procedure, such as 'slide'. + +Collision resolution procedures are called like (RESOLVE RECT1 RECT2 +GOAL). RECT1 is the rectangle that is attempting to move to GOAL, but +has collided with RECT2. It is the responsibility of the resolution +procedure to modify GOAL such that RECT1 and RECT2 are no longer +colliding." + (define (sort-by-area collisions) + ;; This is just an insertion sort, which will be slow if there are a + ;; large number of simultaneous collisions. I think that the number + ;; of simultaneous collisions is almost always a single digit + ;; number, so a more efficient sorting algorithm doesn't gain us + ;; anything. + (define (compare a b) + (match a + ((_ area-a _) + (match b + ((_ area-b _) + (< area-a area-b)))))) + (define (swap i j) + (let ((tmp (array-list-ref collisions i))) + (array-list-set! collisions i (array-list-ref collisions j)) + (array-list-set! collisions j tmp))) + (let ((size (array-list-size collisions))) + (let outer + ((i 0)) + (when (< i size) + (let inner ((j (+ i 1))) + (when (< j size) + (when (compare (array-list-ref collisions i) + (array-list-ref collisions j)) + (swap i j)) + (inner (+ j 1)))) + (outer (+ i 1)))))) + (define (collision? rect1 rect2 goal) + (let ((goal-x (vec2-x goal)) + (goal-y (vec2-y goal))) + (and (< goal-x (rect-right rect2)) + (> (+ goal-x (rect-width rect1)) (rect-left rect2)) + (< goal-y (rect-top rect2)) + (> (+ goal-y (rect-height rect1)) (rect-bottom rect2))))) + (define (overlap-area rect1 rect2 goal) + (let ((goal-x (vec2-x goal)) + (goal-y (vec2-y goal))) + (* (- (min (+ goal-x (rect-width rect1)) (rect-right rect2)) + (max goal-x (rect-left rect2))) + (- (min (+ goal-y (rect-height rect1)) (rect-top rect2)) + (max goal-y (rect-bottom rect2)))))) + (let ((rect (grid-rect-ref grid item)) + (search-rect (grid-scratch-rect grid)) + (collisions (grid-buffer grid)) + (visited (grid-visited grid))) + ;; The search area is the bounding box formed by union of the + ;; current rect and the rect formed by moving it to the desired + ;; position. + (set-rect-x! search-rect (min (vec2-x goal) (rect-x rect))) + (set-rect-y! search-rect (min (vec2-y goal) (rect-y rect))) + (set-rect-width! search-rect (+ (rect-width rect) + (min (- (vec2-x goal) + (rect-x rect)) + 0))) + (set-rect-height! search-rect (+ (rect-height rect) + (min (- (vec2-y goal) + (rect-y rect)) + 0))) + ;; Reset our scratch space. + (array-list-clear! collisions) + ;; Visit every cell in the search area. + (for-each-item-in-area + (lambda (other other-rect) + ;; Since items can occupy multiple cells, we must track which + ;; items have been processed already so that we don't have + ;; duplicate collision results which will almost certainly + ;; yield strange behavior. + (unless (hashq-ref visited other) + ;; The user-provided filter is expected to return a procedure + ;; that can resolve a collision between itself and other, + ;; should one occur. If the items should clip through each + ;; other without any collision, the filter returns #f and we + ;; do not waste any time testing for collision. + (let ((resolve (filter item other))) + (when resolve + (when (collision? rect other-rect goal) + (array-list-push! collisions + (list other + (overlap-area rect other-rect goal) + resolve))))))) + grid search-rect) + ;; Sort collisions by overlap area and return the biggest + ;; collision. There's definitely improvements that can be made in + ;; the heuristic department here, but it's enough for now. + (sort-by-area collisions) + ;; Return the biggest collision. + (if (array-list-empty? collisions) + #f + (array-list-ref collisions 0)))) + +(define (grid-resolve-collisions grid item goal filter) + "Check if moving ITEM in GRID to GOAL causes collisions, and if +so, resolve them using HANDLER." + (let ((visited (grid-visited grid))) + (define (collide) + (match (grid-find-collision grid item goal filter) + ((other _ resolve) + (hashq-set! visited other #t) + (resolve item + (grid-rect-ref grid item) + other + (grid-rect-ref grid other) + goal) + ;; Resolving the collision may have caused an another + ;; collision, so we must perform the collision test again. + ;; This loop continues until the item is no longer colliding + ;; with any other item. + (collide)) + (#f #f))) + ;; Reset shared scratch space. + (hash-clear! visited) + ;; Never check collision against ourselves. + (hashq-set! visited item #t) + (collide))) + +(define (grid-move grid item position filter) + "Attempt to move ITEM in GRID to POSITION (a 2D vector) and check +for collisions. For each collision, HANDLER will be called with two +arguments: ITEM and the item it collided with. If a collision occurs, +POSITION may be modified to resolve the colliding objects." + (grid-resolve-collisions grid item position filter) + (grid-update grid item position)) + +(define (grid-clear grid) + "Remove all items from GRID." + (hash-clear! (grid-rects grid)) + (hash-clear! (grid-rows grid))) + +(define (grid-cell-count grid) + "Return the number of cells in GRID." + (hash-fold (lambda (y row result) + (+ result (hash-count (const #t) row))) + 0 + (grid-rows grid))) + +(define (grid-item-count grid) + "Return the number of items in GRID." + (hash-count (const #t) (grid-rects grid))) diff --git a/examples/grid.scm b/examples/grid.scm new file mode 100644 index 0000000..39ad50e --- /dev/null +++ b/examples/grid.scm @@ -0,0 +1,87 @@ +(use-modules (chickadee) + (chickadee math grid) + (chickadee math vector) + (chickadee math rect) + (chickadee render) + (chickadee render color) + (chickadee render font) + (chickadee render shapes) + (chickadee render sprite) + (chickadee sdl) + (sdl2 input keyboard)) + +(define font #f) +(define grid (make-grid)) +(define item-color (make-color 0.7 0.0 0.0 0.5)) +(define cell-color (make-color 0.0 0.7 0.0 0.2)) +(define player-color (make-color 0.0 0.0 0.7 0.8)) +(define player-pos #v(32.0 32.0)) +(define player-speed 2) +(define player-velocity #v(0.0 0.0)) + +(define (load) + (set! font (load-font "fonts/good_neighbors_starling.xml")) + (grid-add grid 'wall-left 0 0 32 480) + (grid-add grid 'wall-top 32 448 576 32) + (grid-add grid 'wall-right 608 0 32 480) + (grid-add grid 'wall-bottom 32 0 576 32) + (grid-add grid 'box-1 100 100 100 50) + (grid-add grid 'box-2 300 300 50 100) + (grid-add grid 'box-3 350 150 200 75) + (grid-add grid 'box-4 150 140 130 75) + (grid-add grid 'player (vec2-x player-pos) (vec2-y player-pos) 32 32)) + +(define (update dt) + (set-vec2-x! player-velocity 0.0) + (set-vec2-y! player-velocity 0.0) + (when (key-pressed? 'up) + (set-vec2-y! player-velocity player-speed)) + (when (key-pressed? 'down) + (set-vec2-y! player-velocity (- player-speed))) + (when (key-pressed? 'right) + (set-vec2-x! player-velocity player-speed)) + (when (key-pressed? 'left) + (set-vec2-x! player-velocity (- player-speed))) + (vec2-add! player-pos player-velocity) + (grid-move grid 'player player-pos + (lambda (a b) + (if (eq? a 'player) + slide + #f)))) + +(define %cell-rect + (make-rect 0.0 0.0 (grid-cell-size grid) (grid-cell-size grid))) +(define %cell-count-pos + #v(0.0 0.0)) + +(define number->string* + (let ((cache (make-hash-table))) + (lambda (n) + (or (hashv-ref cache n) + (begin + (let ((s (number->string n))) + (hashv-set! cache n s) + s)))))) + +(define (draw alpha) + (with-blend-mode 'alpha + (let ((size (grid-cell-size grid))) + (for-each-cell (lambda (cell x y) + (set-rect-x! %cell-rect (* x size)) + (set-rect-y! %cell-rect (* y size)) + (draw-filled-rect %cell-rect cell-color)) + grid) + (with-batched-sprites + (for-each-cell (lambda (cell x y) + (set-vec2-x! %cell-count-pos (+ (* x size) (/ size 2))) + (set-vec2-y! %cell-count-pos (+ (* y size) (/ size 2))) + (draw-text font (number->string* (cell-count cell)) + %cell-count-pos)) + grid))) + (for-each-item (lambda (item rect) + (if (eq? item 'player) + (draw-filled-rect rect player-color) + (draw-filled-rect rect item-color))) + grid))) + +(run-game/sdl #:load load #:draw draw #:update update) |