src/main.lisp @ 60483297353d

Progress
author Steve Losh <steve@stevelosh.com>
date Wed, 21 Apr 2021 13:36:06 -0400
parents 75152f6efda6
children 2e93a4fe55f1
(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 *calories* 0.0)
(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* (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
   (owned :initform 0)))

(defclass* mushroom (item)
  ((section :initform 'mushrooms)
   (price :initform 1)
   (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))))

(defun make-mushrooms ()
  (make-items 'mushroom
              '((chantrelle "Chantrelle mushroom" :calories 60 :price 1)
                (matsutake "Matsutake mushroom" :calories 200 :price 15))))



;;;; 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 -------------------------------------------------------------------
(defparameter *calories-per-second* 1.0)

(defun hungryp ()
  (< *calories* 100.0))

(defun starvingp ()
  (zerop *calories*))

(defun fullp ()
  (> *calories* 1000.0))

(defun eat (mushroom)
  (incf *calories* (calories mushroom))
  (inv-dec mushroom))

(defun attempt-to-eat ()
  (if (fullp)
    (progn (msg "You're too full to eat any more.")
           (unlock 'money)
           (unlock 'sell))
    (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)))))))

(defun tick/hunger (delta)
  (let ((consumed (* delta *calories-per-second*)))
    (setf *calories* (max 0.0 (- *calories* consumed)))))


;;;; Money --------------------------------------------------------------------
(defvar *money* 0)

(defun sell (mushroom)
  (incf *money* (price mushroom))
  (inv-dec mushroom))

(defun attempt-to-sell ()
  (let ((options (inv-section-items 'mushrooms :only-owned t)))
    (if (null options)
      (msg "You don't have any mushrooms to sell.")
      (progn (msg "You sell a mushroom.")
             (sell (random-elt options #'random))))))


;;;; 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-ooc-colors
    (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 (&rest keys)
  (apply #'hset-insert! *unlocked* keys))

(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*
  (boots:shelf (:fill-char #\space :fill-attr +default+)
    (boots:stack (:fill-char #\space :fill-attr +default+)
      *ui/forest/forage*)
    (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)
                                          (nil 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 feel " ,(cond ((fullp) "full")
                                            ((hungryp) "hungry")
                                            ((starvingp) "starving")
                                            (t "content"))
                          ".")
                +default+)
    (boots:draw pad 0 1 "[Eat a mushroom]" c)))

(defun draw/forest/sell (pad)
  (with-widget sell (c)
    (boots:draw pad 0 0 "Other people want mushrooms too." +default+)
    (boots:draw pad 0 1 "[Sell a mushroom]" 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))


;;;; 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/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*)))


(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))
  (draw-center pad y "INVENTORY" +bold+)
  (incf y 2)
  (when (unlockedp 'money)
    (boots:draw pad 0 y (format nil "$~D" *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 *ui/game*))
     (unwind-protect (progn ,@body)
       (pop (boots:children *ui/game*)))))

(defun tick (delta)
  (tick/hunger delta))

(defun game ()
  (with-ui *ui/game*
    (iterate
      (timing real-time :per-iteration-into delta)
      (incf *elapsed* delta)
      (tick (/ (float delta 1.0d0) internal-time-units-per-second))
      (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)))))


;;;; 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)
  (boots:draw pad 0 0 "Paused" +ooc+)
  (boots:draw pad 0 2 "[R]esume" +ooc+)
  (boots:draw pad 0 3 "[Q]uit Game" +ooc+))

(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 (+ 3 (length *asset/help*))
                     :border t :margin t
                     :fill-char #\space :fill-attr +default+
                     :draw 'draw/help))

(defun draw/help (pad)
  (boots:draw pad 0 0 "HELP" +default+)
  (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
        *calories* (* 1.0 10)
        *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
        (with-in-game-colors
          (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))