src/main.lisp @ 75152f6efda6

Pivot
author Steve Losh <steve@stevelosh.com>
date Tue, 20 Apr 2021 18:01:32 -0400
parents 614ad4a1d44e
children 60483297353d
(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))


;;;; State --------------------------------------------------------------------
(defvar *event* nil)
(defvar *mods* nil)
(defvar *seed* nil)
(defvar *debug* (make-ring-buffer))
(defvar *pcg* (pcg:make-pcg))
(defvar *elapsed* nil)
(defvar *panels* nil)
(defvar *unlocked* nil)
(defvar *messages* (make-ring-buffer :size 16))
(defvar *calories* 0.0)

;;;; Config -------------------------------------------------------------------
(defparameter *forage-chance* 0.1)
(defparameter *calories-per-second* 1.0)

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

(defun unlockedp (key)
  (hset-contains-p *unlocked* key))

(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)
   (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)
                (matsutake "Matsutake mushroom" :calories 200))))


;;;; Inventory ----------------------------------------------------------------
(defvar *inventory* nil)
(defvar *inventory-index* nil)

(defparameter *sections* '(tools mushrooms))

(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 -------------------------------------------------------------------
(defun eat (mushroom)
  (incf *calories* (calories mushroom))
  (inv-dec mushroom))

(defun attempt-to-eat ()
  (let ((options (inv-section-items 'mushrooms :only-owned t)))
    (if (null options)
      nil
      (progn (msg "You feel hungry.  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)))
    (when (zerop *calories*)
      (attempt-to-eat))))

;;;; 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 -------------------------------------------------------------------
(defclass* panel ()
  ((draw-function)
   (selected)
   (key)
   (ui)))

(defgeneric panel-name (panel))


;;;; Forest -------------------------------------------------------------------
(defparameter *ui/forest/forage*
  (boots:make-canvas :height 1 :margin-bottom 1 :draw 'draw/forest/forage))

(defparameter *ui/forest*
  (boots:shelf (:fill-char #\space :fill-attr +default+)
    *ui/forest/forage*))


(defclass* forest (panel)
  ((ui :initform *ui/forest*)
   (key :initform 'forest)))

(defmethod panel-name ((p forest))
  "Forest")

(defun draw/forest/forage (pad)
  (when (unlockedp 'forage)
    (boots:draw pad 0 0 "[Forage for mushrooms]" +default+)))

(chancery:define-rule (random-forage :distribution :weighted)
  (1 'chantrelle)
  (1 'matsutake))


(defun press/forage ()
  (if (randomp *forage-chance*)
    (progn (msg "You found a mushroom.")
           (inv-inc 'mushrooms (random-forage)))
    (msg "You don't find anything.")))


;;;; 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)
  (draw-center pad 0 "INVENTORY" +bold+)
  (iterate
    (with y = 2)
    (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/forage))
        (#\tab (msg "TODO"))
        (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
        *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))