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))