src/main.lisp @ cb3863ea23c1 default tip
More
| author | Steve Losh <steve@stevelosh.com> |
|---|---|
| date | Thu, 22 Apr 2021 00:50:55 -0400 |
| parents | 9d304403bb0b |
| children | (none) |
(in-package :mycelium) ;;;; Constants ---------------------------------------------------------------- (defconstant +black+ (boots:rgb 0 0 0)) (defconstant +white+ (boots:rgb 255 255 255)) (defconstant +dark-purple+ (boots:rgb #x52 #x2F #x70)) (defconstant +default+ (boots:attr :fg +white+ :bg +black+)) (defconstant +bold+ (boots:attr :fg +white+ :bg +black+ :bold t)) (defconstant +reverse+ (boots:attr :fg +black+ :bg +white+)) (defconstant +selected+ (boots:attr :fg +white+ :bg +dark-purple+ :bold t)) (defconstant +button-default+ (boots:attr :fg +white+ :bg +black+ :bold t)) (defconstant +button-selected+ (boots:attr :fg +white+ :bg +dark-purple+ :bold t)) ;;;; State -------------------------------------------------------------------- (defvar *event* nil) (defvar *mods* nil) (defvar *seed* nil) (defvar *debug* (make-ring-buffer)) (defvar *pcg* (pcg:make-pcg)) (defvar *elapsed* nil) (defvar *messages* (make-ring-buffer :size 16)) (defvar *focused* nil) ;;;; Assets ------------------------------------------------------------------- (defun load-asset (path) (_ path alexandria:read-file-into-string (string-right-trim '(#\newline) _) str:lines)) (defparameter *asset/splash* (load-asset "assets/splash.txt")) (defparameter *asset/help* (load-asset "assets/help.txt")) ;;;; Utils -------------------------------------------------------------------- (defun random (bound &optional max inclusive?) (pcg:pcg-random *pcg* bound max inclusive?)) (defun press-key (event) (loop :until (multiple-value-call #'boots:event= event (boots:read-event))) (values)) (defmacro with-ui (ui &body body) (alexandria:with-gensyms (prev) `(let ((,prev (boots:root boots:*screen*))) (setf (boots:root boots:*screen*) ,ui) (unwind-protect (progn ,@body) (setf (boots:root boots:*screen*) ,prev))))) (defmacro event-case (form &body clauses) (alexandria:with-gensyms (e m) `(multiple-value-bind (,e ,m) ,form (unless (handle-global-event ,e ,m) (boots:event-case (values ,e ,m) ,@clauses))))) (defun handle-global-event (event mods) (boots:event-case (values event mods) (#(:ctrl #\q) (throw 'quit nil)) (#\esc (pause) t) (#\? (help) t) (t nil))) (defun draw-right (pad x y string attr) (boots:draw pad (1+ (- x (length string))) y string attr)) (defun draw-center (pad y string attr &aux (s (string string)) (w (boots:width pad)) (l (length s))) (boots:draw pad (if (<= w l) 0 (truncate (- w l) 2)) y s attr)) (defun dbg (&optional string &rest args) (if string (rb-push *debug* (apply #'format nil string args)) (do-ring-buffer (s *debug*) (write-line s))) (values)) (defun msg (string &rest args) (rb-push *messages* (if (null args) (aesthetic-string string) (apply #'format nil string args)))) ;;;; Items -------------------------------------------------------------------- ;;; These classes don't represent individual items, but rather the whole ;;; set/count/pile of a particular type of item. (defclass* item () (section key description (price :initform 0) (owned :initform 0))) (defclass* mushroom (item) ((section :initform 'mushrooms) (calories :initform 100))) (defclass* tool (item) ((section :initform 'tools))) (defmethod print-object ((o item) s) (print-unreadable-object (o s :type t) (format s "~A" (key o)))) (defun make-items (class data) (iterate (for (key description . initargs) :in data) (collect (apply #'make-instance class :key key :description description initargs)))) (defun make-tools () (make-items 'tool '((trowel "Small trowel" :owned 1) (bucket "Sturdy bucket" :owned 1) (field-guide "Field guide" :price 1999)))) (defun make-mushrooms () (make-items 'mushroom '((chantrelle "Chantrelle mushroom" :calories 60 :price 15) (matsutake "Matsutake mushroom" :calories 200 :price 1000)))) ;;;; Inventory ---------------------------------------------------------------- (defvar *inventory* nil) (defvar *inventory-index* nil) (defparameter *sections* '(mushrooms tools)) (defun inv-ref (section key) ;; should have been a setf expander but time is short (gethash key (gethash section *inventory*))) (defun inv-inc (o &optional key) (if (null key) (incf (owned (gethash (key o) (gethash (section o) *inventory*))) 1) (incf (owned (gethash key (gethash o *inventory*))) 1))) (defun inv-dec (o &optional key) (if (null key) (decf (owned (gethash (key o) (gethash (section o) *inventory*))) 1) (decf (owned (gethash key (gethash o *inventory*))) 1))) (defun inv-insert (&rest items) (dolist (item items) (setf (gethash (key item) (gethash (section item) *inventory*)) item))) (defun inv-section-items (section &key only-owned) (iterate (for (nil item) :in-hashtable (gethash section *inventory*)) (when (or (not only-owned) (plusp (owned item))) (collect item)))) (defun init/inventory () (setf *inventory* (make-hash-table)) (dolist (s *sections*) (setf (gethash s *inventory*) (make-hash-table))) (apply #'inv-insert (make-mushrooms)) (apply #'inv-insert (make-tools)) (setf *inventory-index* (iterate (for s :in *sections*) (for keys = (sort (alexandria:hash-table-keys (gethash s *inventory*)) #'string<)) (collect (list s keys))))) ;;;; Hunger ------------------------------------------------------------------- (defvar *calories* 0.0) (defparameter *calories-per-second* 1.0) (defparameter *calories/hungry* 100.0) (defparameter *calories/full* 600.0) (defparameter *calories/max* 1000.0) (defun hungryp () (< *calories* *calories/hungry*)) (defun starvingp () (zerop *calories*)) (defun fullp () (> *calories* *calories/full*)) (defun eat (mushroom) (incf *calories* (calories mushroom)) (clampf *calories* 0 *calories/max*) (inv-dec mushroom)) (defun attempt-to-eat () (if (fullp) (msg "You're too full to eat any more.") (let ((options (inv-section-items 'mushrooms :only-owned t))) (if (null options) (msg "You don't have any mushrooms to eat.") (progn (msg "You eat a mushroom.") (eat (random-elt options #'random)) (when (fullp) (unlock 'money) (when (unlock 'sell) (ensure-customer)))))))) (defun tick/hunger (delta) (decf *calories* (* delta *calories-per-second*)) (clampf *calories* 0 *calories/max*)) ;;;; Money -------------------------------------------------------------------- (defvar *money* 0) (defvar *offered* nil) (defvar *customer* nil) (defparameter *customers-per-second* (float 1/1)) (defun human-money (money) (multiple-value-bind (dollars cents) (truncate money 100) (format nil "$~D.~2,'0D" dollars cents))) (defun offer (section key) (push (cons section key) *offered*)) (chancery:define-string random-customer "A man" "A woman" "A mycologist" "A chef") (defun ensure-customer () (when (null *customer*) (setf *customer* (random-customer)) t)) (defun sell (mushroom) (incf *money* (price mushroom)) (inv-dec mushroom) (setf *customer* nil) (when (unlock 'buy) (offer 'tools 'field-guide))) (defun attempt-to-sell () (let ((options (inv-section-items 'mushrooms :only-owned t))) (cond ((null options) (msg "You don't have any mushrooms to sell.")) ((null *customer*) (msg "No one wants to buy your mushrooms.")) (t (progn (msg "You sell a mushroom.") (sell (random-elt options #'random))))))) (defun attempt-to-buy (id) (destructuring-bind (section . key) id (let* ((item (inv-ref section key)) (price (price item))) (cond ((> price *money*) (msg "You can't afford that.")) (t (progn (decf *money* price) (msg "You buy ~A." (description item)) (alexandria:removef *offered* id :test #'equal) (unlock 'hire))))))) (defun available-to-buy () (iterate (for (section . key) :in *offered*) (collect (inv-ref section key)))) (defun tick/customers (delta) (when (randomp (* *customers-per-second* delta)) (when (ensure-customer) (msg "~A wanders by and wants to buy a mushroom." *customer*)))) ;;;; Ambience ----------------------------------------------------------------- (defparameter *ambient-events-per-second* (float 1/60)) (chancery:define-string random-ambient "You hear the patter of rain on the forest canopy." "A bird calls in the woods." "The smell of loam fills your nostrils.") (defun tick/ambience (delta) (when (randomp (* *ambient-events-per-second* delta)) (if (or (hungryp) (starvingp)) (msg "Your stomach rumbles.") (msg (random-ambient))))) ;;;; Splash ------------------------------------------------------------------- (defparameter *ui/splash/bg* (boots:make-canvas :draw 'draw/splash/bg)) (defparameter *ui/splash/card* (boots:make-canvas :width (reduce #'max *asset/splash* :key #'length) :height (length *asset/splash*) :border t :margin t :fill-char #\space :fill-attr +default+ :draw 'draw/splash/card)) (defparameter *ui/splash* (boots:pile () *ui/splash/card* *ui/splash/bg*)) (defun draw/splash/card (pad) (iterate (for y :from 0) (for line :in *asset/splash*) (boots:draw pad 0 y line +default+))) (defun draw/splash/bg (pad) (dotimes (x (boots:width pad)) (dotimes (y (boots:height pad)) (boots:draw pad x y (losh:random-elt "\\/_-,.`" #'random) (boots:attr :fg +dark-purple+))))) (defun splash () (with-ui *ui/splash* (boots:redraw) (press-key #\space)) (game)) ;;;; Panels ------------------------------------------------------------------- (defvar *panels* nil) (defclass* panel () ((draw-function) (selected) (key) (ui))) (defgeneric panel-name (panel)) (defgeneric panel-widgets (panel)) (defun current-panel () (find-if #'selected *panels*)) ;;;; Widgets ------------------------------------------------------------------ (defvar *unlocked* nil) (defun unlock (key) (prog1 (not (hset-contains-p *unlocked* key)) (hset-insert! *unlocked* key))) (defun unlockedp (key) (hset-contains-p *unlocked* key)) (defun widget-pos (key) (let ((widgets (panel-widgets (current-panel)))) (destructuring-bind (rows cols) (array-dimensions widgets) (do-range ((r 0 rows) (c 0 cols)) (when (eql key (aref widgets r c)) (return-from widget-pos (values r c))))))) (defun widget-ref (row col) (let ((widgets (panel-widgets (current-panel)))) (destructuring-bind (rows cols) (array-dimensions widgets) (aref widgets (mod row rows) (mod col cols))))) (defun focusedp (key) (assert key) (eql key *focused*)) (defun focus (key) (assert key) (setf *focused* key)) (defun focus-dir (dr dc) (multiple-value-bind (r c) (widget-pos *focused*) (iterate (incf r dr) (incf c dc) (for key = (widget-ref r c)) (when (and key (unlockedp key)) (focus key) (return))))) (defmacro with-widget (key (color) &body body) `(when (unlockedp ',key) (let ((,color (if (focusedp ',key) +button-selected+ +button-default+))) ,@body))) (defgeneric press (widget-key)) ;;;; Forest ------------------------------------------------------------------- (defparameter *ui/forest/forage* (boots:make-canvas :height 2 :margin-bottom 1 :draw 'draw/forest/forage)) (defparameter *ui/forest/eat* (boots:make-canvas :height 2 :margin-bottom 1 :draw 'draw/forest/eat)) (defparameter *ui/forest/sell* (boots:make-canvas :height 2 :margin-bottom 1 :draw 'draw/forest/sell)) (defparameter *ui/forest/buy* (boots:make-canvas :height 2 :margin-bottom 1 :draw 'draw/forest/buy)) (defparameter *ui/forest* (boots:shelf (:fill-char #\space :fill-attr +default+) (boots:stack (:fill-char #\space :fill-attr +default+) *ui/forest/forage* *ui/forest/buy*) (boots:stack (:fill-char #\space :fill-attr +default+) *ui/forest/eat* *ui/forest/sell*))) (defclass* forest (panel) ((ui :initform *ui/forest*) (key :initform 'forest))) (defmethod panel-name ((p forest)) "Forest") (defmethod panel-widgets ((p forest)) #2A((forage eat) (buy sell))) (defun draw/forest/forage (pad) (with-widget forage (c) (boots:draw pad 0 0 "The forest is peaceful." +default+) (boots:draw pad 0 1 "[Forage for mushrooms]" c))) (defun draw/forest/eat (pad) (with-widget eat (c) (boots:draw pad 0 0 `("You " ,(cond ((fullp) "feel full") ((starvingp) "are starving") ((hungryp) "are hungry") (t "feel content")) ".") +default+) (boots:draw pad 0 1 "[Eat a mushroom]" c))) (defun draw/forest/sell (pad) (with-widget sell (c) (if *customer* (boots:draw pad 0 0 (format nil "~A wants to buy a mushroom." *customer*) +default+) (boots:draw pad 0 0 "No one wants to buy mushrooms." +default+)) (boots:draw pad 0 1 "[Sell a mushroom]" c))) (defun draw/forest/buy (pad) (with-widget buy (c) (if *offered* (boots:draw pad 0 0 "Time for a shopping trip?" +default+) (boots:draw pad 0 0 "Nothing's for sale right now." +default+)) (boots:draw pad 0 1 "[Buy something]" c))) (chancery:define-rule (random-forage :distribution :weighted) (1 'chantrelle) (1 'matsutake)) (defparameter *base-forage-chance* 0.1) (defun forage-chance () (* *base-forage-chance* (cond ((fullp) 1.1) ((hungryp) 2.0) ((starvingp) 0.5) (t 1.0)))) (defmethod press ((k (eql 'forage))) (if (randomp (forage-chance)) (progn (msg "You found a mushroom.") (inv-inc 'mushrooms (random-forage)) (unlock 'eat)) (msg "You don't find anything."))) (defmethod press ((k (eql 'eat))) (attempt-to-eat)) (defmethod press ((k (eql 'sell))) (attempt-to-sell)) (defmethod press ((k (eql 'buy))) (if *offered* (let* ((options (iterate (for i :in (available-to-buy)) (for s = (format nil "~A - ~A" (human-money (price i)) (description i))) (collect (cons s (cons (section i) (key i)))))) (chosen (choose "What do you want to buy?" options))) (when chosen (attempt-to-buy chosen))) (msg "There's nothing you can buy right now."))) ;;;; Game --------------------------------------------------------------------- (defparameter *ui/game/top-bar* (boots:shelf (:fill-char #\space :fill-attr +default+ :height 1 :border-bottom t) (boots:make-canvas :fill-char #\space :fill-attr +default+ :draw 'draw/game/top-bar/panels :margin-right t) (boots:make-canvas :width 10 :fill-char #\space :fill-attr +default+ :draw 'draw/game/top-bar/time))) (defparameter *ui/game/bottom-bar* (boots:make-canvas :height 1 :border-top t :fill-char #\space :fill-attr +default+ :draw 'draw/game/bottom-bar)) (defparameter *ui/game/messages* (boots:make-canvas :height 8 :border-top t :fill-char #\space :fill-attr +default+ :draw 'draw/game/messages)) (defparameter *ui/game/inventory* (boots:make-canvas :width 35 :border-left t :fill-char #\space :fill-attr +default+ :draw 'draw/game/inventory)) (defparameter *ui/game/panel* (boots:pile () (boots:make-canvas :fill-char #\? :fill-attr +default+))) (defparameter *ui/game* (boots:pile (:fill-char #\space :fill-attr +default+) (boots:stack () *ui/game/top-bar* (boots:shelf () *ui/game/panel* *ui/game/inventory*) *ui/game/messages* *ui/game/bottom-bar*))) (defvar *layer-container* *ui/game*) (defun draw/game/bottom-bar (pad) (draw-right pad (1- (boots:width pad)) 0 "[?] Help [ESC] Pause/Quit" +default+)) (defun draw/game/top-bar/panels (pad) (boots:draw pad 0 0 (iterate (for panel :in-vector *panels*) (collect (if (selected panel) +selected+ +reverse+)) (collect (panel-name panel))))) (defun draw/game/top-bar/time (pad) (draw-right pad (1- (boots:width pad)) 0 (format nil "~Ds" (truncate *elapsed* internal-time-units-per-second)) +default+)) (defun draw/game/messages (pad) ;; todo make reverse iteration stuff for ring buffers (iterate (for i :from -1 :downto (- (rb-count *messages*))) (for y :from 0) (boots:draw pad 0 y (rb-ref *messages* i) +default+))) (defun draw/game/inventory (pad &aux (y 0)) (when (unlockedp 'money) (boots:draw pad 0 y (human-money *money*) +default+) (incf y 2)) (iterate (with h = (boots:height pad)) (while (< y h)) (for sy = y) (for (section keys) :in *inventory-index*) (iterate (for key :in keys) (for item = (inv-ref section key)) (when (plusp (owned item)) (incf y) (boots:draw pad 0 y (format nil "~6D ~A" (owned item) (description item)) +default+))) (unless (= sy y) ;; only draw section header if we drew anything else (boots:draw pad 0 sy (symbol-name section) +default+) (incf y 2)))) (defmacro with-layer (ui &body body) `(progn (push ,ui (boots:children *layer-container*)) (unwind-protect (progn ,@body) (pop (boots:children *layer-container*))))) (defun tick (delta) (tick/hunger delta) (tick/ambience delta) (tick/customers delta)) (defmacro ticking-loop (&body body) (alexandria:with-gensyms (delta) `(iterate (timing real-time :per-iteration-into ,delta) (incf *elapsed* ,delta) (tick (/ (float ,delta 1.0d0) internal-time-units-per-second)) (progn ,@body)))) (defun game () (with-ui *ui/game* (ticking-loop (setf (first (boots:children *ui/game/panel*)) (ui (find-if #'selected *panels*))) (boots:redraw) (event-case (boots:read-event-no-hang) (nil (boots:wait 1/30)) (#\newline (press *focused*)) (#\tab (msg "TODO")) (:left (focus-dir 0 -1)) (:right (focus-dir 0 1)) (:up (focus-dir -1 0)) (:down (focus-dir 1 0)) (t nil))))) ;;;; Chooser Box -------------------------------------------------------------- (defun choose (heading options &aux (selected 0) (n (length options))) ; options: (option . result) (with-layer (boots:canvas (:fill-char #\space :fill-attr +default+ :margin t :border t :height (+ 2 n) :width (reduce #'max options :key (alexandria:compose #'length #'car) :initial-value (length heading))) (pad) (boots:draw pad 0 0 heading +default+) (iterate (for (option . nil) :in options) (for i :from 0) (for y :from 2) (for c = (if (= i selected) +button-selected+ +button-default+)) (boots:draw pad 0 y option c))) (ticking-loop (setf selected (mod selected n)) (boots:redraw) (boots:event-case (boots:read-event-no-hang) (nil nil) (#\newline (return-from choose (cdr (elt options selected)))) (#\esc (return-from choose nil)) (:up (incf selected)) (:down (decf selected)) (t nil))))) ;;;; Pause -------------------------------------------------------------------- (defparameter *ui/pause* (boots:make-canvas :width 30 :height 4 :border t :margin t :fill-char #\space :fill-attr +default+ :draw 'draw/pause)) (defun draw/pause (pad) (draw-center pad 0 "PAUSE" +bold+) (boots:draw pad 0 2 "[R]esume" +default+) (boots:draw pad 0 3 "[Q]uit Game" +default+)) (defun pause () (with-ui *ui/pause* (loop (boots:redraw) (multiple-value-bind (e m) (boots:read-event) (boots:event-case (values e m) ((#\r #\esc) (return-from pause)) (#\q (throw 'quit nil)) (t (setf *event* e *mods* m))))))) ;;;; Help -------------------------------------------------------------------- (defparameter *ui/help* (boots:make-canvas :width 50 :height (+ 2 (length *asset/help*)) :border t :margin t :fill-char #\space :fill-attr +default+ :draw 'draw/help)) (defun draw/help (pad) (draw-center pad 0 "HELP" +bold+) (iterate (for y :from 2) (for line :in *asset/help*) (boots:draw pad 0 y line +default+))) (defun help () (with-ui *ui/help* (boots:redraw) (press-key t))) ;;;; Main --------------------------------------------------------------------- (defun init () (rb-clear *debug*) (rb-clear *messages*) (init/inventory) (setf *seed* (cl:random (expt 2 60) (cl:make-random-state)) *pcg* (pcg:make-pcg :seed *seed*) *elapsed* 0 *focused* 'forage *money* 0 *offered* nil *calories* (* 1.0 60) *layer-container* *ui/game* *panels* (vector (make-instance 'forest :selected t)) *unlocked* (make-hash-set :initial-contents '(forest forage))) (values)) (defun run () (init) (boots/terminals/ansi:with-ansi-terminal (terminal :truecolor t) (boots:with-screen (boots:*screen* terminal) (boots:with-light-borders (catch 'quit (splash)))))) (defun toplevel () (sb-ext:disable-debugger) (run)) (defun build () (sb-ext:save-lisp-and-die "build/mycelium" :executable t :save-runtime-options t :toplevel #'mycelium:toplevel))