This commit is contained in:
2026-07-14 23:14:52 -06:00
parent cd5b99f150
commit da4451560e
11 changed files with 198 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
(defproject il "0.1.0-SNAPSHOT" (defproject il "0.1.0-SNAPSHOT"
:description "Iron Legacy, a browser based sci-fi MMORPG" :description "Iron Legacy, a browser based high-fantasy MMORPG"
:url "http://example.com/FIXME" :url "http://example.com/FIXME"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"} :url "https://www.eclipse.org/legal/epl-2.0/"}
@@ -8,6 +8,7 @@
[org.clojure/data.json "2.5.2"] [org.clojure/data.json "2.5.2"]
[compojure "1.7.2"] [compojure "1.7.2"]
[hiccup "2.0.0"] [hiccup "2.0.0"]
[metosin/jsonista "1.0.0"]
[org.slf4j/slf4j-simple "2.0.16"] [org.slf4j/slf4j-simple "2.0.16"]
[org.clojure/data.json "2.5.2"] [org.clojure/data.json "2.5.2"]
[ring/ring-core "1.15.4"] [ring/ring-core "1.15.4"]

View File

@@ -1,5 +1,5 @@
{:source-paths ["src"] {:source-paths ["src"]
:dependencies [] :dependencies [[org.clojure/core.async "1.9.865"]]
:builds :builds
{:app {:app
{:target :browser {:target :browser

View File

@@ -3,6 +3,9 @@
[clojure.string :as str] [clojure.string :as str]
["pixi.js" :as pixi] ["pixi.js" :as pixi]
[goog.dom :as dom] [goog.dom :as dom]
[cljs.core.async :as async :refer [go go-loop <! >!]]
[il.common.race :as race]
[il.common.class :as class]
[frontend.il.entity :as entity] [frontend.il.entity :as entity]
[frontend.il.update :as update])) [frontend.il.update :as update]))
@@ -31,16 +34,28 @@
(js/window.addEventListener "keydown" (partial handle-key state velocity-for-key-down)) (js/window.addEventListener "keydown" (partial handle-key state velocity-for-key-down))
(js/window.addEventListener "keyup" (partial handle-key state (fn [_] 0)))) (js/window.addEventListener "keyup" (partial handle-key state (fn [_] 0))))
(defn ticker [app state layers entity-id t] (defn- drain-outbox! [{:keys [outbox] :as state} tx-chan]
(println "Draining outbox: " outbox)
(when (count outbox)
(go
(doseq [msg outbox]
(println "Writing event: " msg)
(>! tx-chan (js/JSON.stringify (clj->js msg))))))
(assoc state :outbox []))
(defn ticker [app state layers entity-id rx-chan tx-chan t]
(let [dt (.-deltaTime t) (let [dt (.-deltaTime t)
{world-layer :world-layer {world-layer :world-layer
entity-layer :entity-layer} layers entity-layer :entity-layer} layers
global-mouse-pos (.. app -renderer -events -pointer -global) global-mouse-pos (.. app -renderer -events -pointer -global)
entity-relative-mouse-pos (.toLocal ^js entity-layer global-mouse-pos) entity-relative-mouse-pos (.toLocal ^js entity-layer global-mouse-pos)
_ (println @state) send-interval 25 ; Send every 25ms (10 tps)
{:keys [player entities]} (-> @state elapsed (.-elapsedMS t)
{:keys [player entities]} (-> (update @state :elapsed + elapsed)
(update/update-entities dt layers) (update/update-entities dt layers)
(update/update-player dt layers entity-id entity-relative-mouse-pos) (update/update-player dt send-interval layers entity-id entity-relative-mouse-pos)
(update/update-from-server rx-chan)
(drain-outbox! tx-chan)
(->> (swap! state merge))) (->> (swap! state merge)))
sx (- (/ (.. app -screen -width) 2) (player :x)) sx (- (/ (.. app -screen -width) 2) (player :x))
sy (- (/ (.. app -screen -height) 2) (player :y)) sy (- (/ (.. app -screen -height) 2) (player :y))
@@ -67,29 +82,38 @@
state (atom {:player {:velocity {:x 0 :y 0} state (atom {:player {:velocity {:x 0 :y 0}
:keymap {:w 0 :a 0 :s 0 :d 0 :lmb 0} :keymap {:w 0 :a 0 :s 0 :d 0 :lmb 0}
:last-attack -1 :last-attack -1
:attack-speed 666 :attack-speed 0.66
:meta {:type "player" :meta {:name "Player"
:name "Player" :race race/human
:race "Human" :class class/priest}
:class "Priest"}
:sprite "https://pixijs.com/assets/bunny.png" :sprite "https://pixijs.com/assets/bunny.png"
:speed 10 :speed 10
:renderable nil} :renderable nil}
:entities {(-> entity-id (swap! inc) (keyword)) {:x 500 :y 500 :sprite "https://pixijs.com/assets/bunny.png"}}}) :elapsed 0
:outbox []
:entities {}})
width (.-width js/window.screen) width (.-width js/window.screen)
height (.-height js/window.screen) height (.-height js/window.screen)
stage (.-stage app) stage (.-stage app)
world-layer (pixi/Container.) world-layer (pixi/Container.)
entity-layer (pixi/Container.) entity-layer (pixi/Container.)
ui-layer (pixi/Container.) ui-layer (pixi/Container.)
tx-chan (async/chan (async/sliding-buffer 10))
rx-chan (async/chan (async/sliding-buffer 2))
layers {:entity-layer entity-layer layers {:entity-layer entity-layer
:world-layer world-layer :world-layer world-layer
:ui-layer ui-layer} :ui-layer ui-layer}
websocket (doto (js/WebSocket. (str "ws://" js/location.host "/ws")) websocket (doto (js/WebSocket. (str "ws://" js/location.host "/ws"))
(.addEventListener "open" #(println "Websocket opened.")) (.addEventListener "open" #(go-loop []
(.addEventListener "message" #(println "Received message " (.-data %))) (js/console.log %)
(when-let [msg (<! tx-chan)]
(.send (.-target %) msg)
(recur))))
(.addEventListener "message" #(async/put! rx-chan (-> %
(.-data)
(js/JSON.parse)
(js->clj :keywordize-keys true))))
(.addEventListener "close" #(println "Websocket closed.")))] (.addEventListener "close" #(println "Websocket closed.")))]
(.. app (.. app
(init #js {:width width :height height :backgroundColor 0x222}) (init #js {:width width :height height :backgroundColor 0x222})
(then (fn [_] (then (fn [_]
@@ -110,4 +134,4 @@
(partial sb-callback false)) (partial sb-callback false))
player (assoc e :renderable p :shootbox sb)] player (assoc e :renderable p :shootbox sb)]
(swap! state update-in [:player] merge player) (swap! state update-in [:player] merge player)
(.. app -ticker (add (partial ticker app state layers entity-id))))))))) (.. app -ticker (add (partial ticker app state layers entity-id rx-chan tx-chan)))))))))

View File

@@ -1,6 +1,7 @@
(ns frontend.il.update (ns frontend.il.update
(:require (:require
[frontend.il.entity :as entity])) [frontend.il.entity :as entity]
[cljs.core.async :as async]))
(defn apply-velocity [e dt] (defn apply-velocity [e dt]
(if (contains? e :velocity) (if (contains? e :velocity)
@@ -21,7 +22,7 @@
(defn apply-player-shoot [state entity-id mouse-pos] (defn apply-player-shoot [state entity-id mouse-pos]
(if (and (get-in state [:player :keymap :lmb] false) (if (and (get-in state [:player :keymap :lmb] false)
(< (+ (get-in state [:player :last-attack] -1) (< (+ (get-in state [:player :last-attack] -1)
(get-in state [:player :attack-speed] 888)) (* 1000 (get-in state [:player :attack-speed] 0.75)))
(js/Date.now))) (js/Date.now)))
(let [player-sprite (get-in state [:player :renderable]) (let [player-sprite (get-in state [:player :renderable])
speed 15 speed 15
@@ -36,17 +37,31 @@
h (js/Math.sqrt (+ (* dx dx) (* dy dy))) h (js/Math.sqrt (+ (* dx dx) (* dy dy)))
vx (* (/ dx h) speed) vx (* (/ dx h) speed)
vy (* (/ dy h) speed) vy (* (/ dy h) speed)
eid (swap! entity-id inc) eid (-> (swap! entity-id inc)
(str)
(keyword))
e {:sprite image e {:sprite image
:hyp h :hypotenuse h
:x start-x :x start-x
:y start-y :y start-y
:velocity {:x vx :y vy}}] :velocity {:x vx :y vy}}]
(-> state (-> state
(assoc-in [:entities (keyword (str eid))] e) (update :outbox conj {:type "shoot"})
(assoc-in [:entities eid] e)
(assoc-in [:player :last-attack] (js/Date.now)))) (assoc-in [:player :last-attack] (js/Date.now))))
state)) state))
(defn apply-player-position [state send-interval]
(let [{elapsed :elapsed
{x :x y :y lx :last-x ly :last-y} :player} state]
(if (and (>= elapsed send-interval)
(not (and (= lx x) (= ly y))))
(-> state
(assoc :elapsed (mod elapsed send-interval))
(update :player assoc :last-x x :last-y y)
(update :outbox conj {:type "position" :x x :y y}))
state)))
(defn update-entities [state dt layers] (defn update-entities [state dt layers]
(let [{entities :entities} state (let [{entities :entities} state
new-entities (update-vals entities #(-> % new-entities (update-vals entities #(-> %
@@ -54,10 +69,25 @@
(apply-velocity dt)))] (apply-velocity dt)))]
(assoc state :entities new-entities))) (assoc state :entities new-entities)))
(defn update-player [state dt layers entity-id mouse-pos] (defn update-player [state dt send-interval layers entity-id mouse-pos]
(let [{player :player} state (let [{player :player} state
updated-player (-> player updated-player (-> player
(apply-renderable layers) (apply-renderable layers)
(apply-velocity dt))] (apply-velocity dt))]
(-> (assoc state :player updated-player) (-> (assoc state :player updated-player)
(apply-player-position send-interval)
(apply-player-shoot entity-id mouse-pos)))) (apply-player-shoot entity-id mouse-pos))))
(defn- deep-merge [a b]
(if (and (map? a) (map? b))
(merge-with deep-merge a b)
b))
(defn update-from-server [state rx-chan]
(loop [state-acc state
{:keys [kind] :as msg} (async/poll! rx-chan)]
(if (and msg (= kind "sync"))
(recur
(deep-merge state-acc msg)
(async/poll! rx-chan))
state-acc)))

View File

@@ -1,24 +0,0 @@
(ns il.class)
(def priest {:name "priest"
:stats { :atkspeed 0.5 }})
(def warden "warden")
(def sorcerer "sorcerer")
(def magician "magician")
(def thief "thief")
(def ranger "ranger")
(def artificer "artificer")
(def corsair "corsair")
(def astromancer "astromancer")
(def valid-class? [class]
(let [classes [priest
warden
sorcerer
magician
thief
ranger
artificer
corsair
astromancer]]
(contains? classes class)))

33
src/il/common/class.cljc Normal file
View File

@@ -0,0 +1,33 @@
(ns il.common.class)
(def priest {:name "priest"
:stats {:hp 90}})
(def warden {:name "priest"
:stats {:hp 120}})
(def sorcerer {:name "sorcerer"
:stats {:hp 90}})
(def magician {:name "magician"
:stats {:hp 80}})
(def thief {:name "thief"
:stats {:hp 80}})
(def ranger {:name "ranger"
:stats {:hp 80}})
(def artificer {:name "artificer"
:stats {:hp 90}})
(def corsair {:name "corsair"
:stats {:hp 80}})
(def astromancer {:name "astromancer"
:stats {:hp 100}})
(def all [priest
warden
sorcerer
magician
thief
ranger
artificer
corsair
astromancer])
(defn valid-class? [c]
(some #{c} (map :name all)))

3
src/il/common/race.cljc Normal file
View File

@@ -0,0 +1,3 @@
(ns il.common.race)
(def human "human")

View File

@@ -3,24 +3,31 @@
[ring.websocket.async :refer [go-websocket]] [ring.websocket.async :refer [go-websocket]]
[compojure.core :as c] [compojure.core :as c]
[compojure.route :as route] [compojure.route :as route]
[clojure.core.async :refer [<! >! go-loop]] [clojure.core.async :as async :refer [<! >! go-loop]]
[clojure.data.json :as json] [hiccup.core :as h]
[hiccup.core :as h])) [il.state :as s]
[il.json :as j]
[il.handler :as handler]))
(defn ws-handler [_] (defn ws-handler [state state-chan player-id]
(go-websocket [in out err] (go-websocket [in out err]
(s/change state-chan update :players assoc player-id {:out out :in in :err err})
(go-loop [] (go-loop []
(when-let [e (<! err)] (when-let [e (<! err)]
(println (.getMessage e)) (println (.getMessage e))
(recur))) (recur)))
(go-loop [] (go-loop []
(if-let [msg (<! in)] (if-let [raw (<! in)]
(do (let [msg (j/from-json raw)
type (get msg :type "invalid")]
(cond (cond
(= msg "world") (>! out "Hello World!")) (= type "shoot") (handler/handle-shoot state state-chan player-id msg)
(= type "invalid") (>! out "Hello World!"))
(println msg) (println msg)
(recur)) (recur))
(println "Closed."))))) (do
(s/change state-chan update :players dissoc player-id)
(println "Closed: " player-id))))))
(defn index-handler [_] (defn index-handler [_]
(h/html (h/html
@@ -32,14 +39,42 @@
[:script {:src "/js/app.js" :defer true}]] [:script {:src "/js/app.js" :defer true}]]
[:body]])) [:body]]))
(c/defroutes app (defn build-application [state state-chan]
(c/GET "/" r (index-handler r)) (c/routes
(c/GET "/ws" r (ws-handler r)) (c/GET "/" r (index-handler r))
(route/resources "/") (c/GET "/ws" [] (ws-handler @state state-chan (random-uuid))) ; TODO: Auth here
(route/not-found "<h1>The requested resource does not exist...</h1>")) (route/resources "/")
(route/not-found "<h1>The requested resource does not exist...</h1>")))
(defn game-loop [state state-chan]
(go-loop []
(let [start-time (System/currentTimeMillis)
max-inputs-per-tick 15000 ; Stops the game from spinning endlessly, dropping ticks
events-to-apply (loop [inputs []
count 0]
(if (>= count max-inputs-per-tick)
inputs
(if-let [args (async/poll! state-chan)]
(recur (conj inputs args) (inc count))
inputs)))]
(doseq [event-args events-to-apply]
(apply swap! state event-args))
(doseq [{out :out} (vals (:players @state))]
(>! out (j/to-json {:type "sync"
:entities (:entities @state)})))
(let [elapsed (- (System/currentTimeMillis) start-time)
sleep-time (max 0 (- 50 elapsed))]
(<! (async/timeout sleep-time))))
(recur)))
(defn -main [] (defn -main []
(let [port 5000] (let [state (atom {:systems []
(jetty/run-jetty :entities {}
app :players {}})
{:port port :join? true :max-idle-timeout 60000}))) ; Allow 60 second idle on websockets state-chan (async/chan)
app (build-application state state-chan)]
(game-loop state state-chan)
(let [port 5000]
(jetty/run-jetty
app
{:port port :join? true :max-idle-timeout 60000})))) ; Allow 60 second idle on websockets

18
src/il/handler.clj Normal file
View File

@@ -0,0 +1,18 @@
(ns il.handler
(:require [il.state :as s]))
(defn handle-shoot [state state-chan player-id msg]
(let [bullet-id (random-uuid)
{x :x y :y} (get-in state [:players player-id])
{tx :x ty :y} msg
dx (- tx x)
dy (- ty y)
h (Math/sqrt (+ (Math/pow dx 2) (Math/pow dy 2)))
speed 15 ; TODO: Calculate projectile speed based on player stats
vx (* (/ dx h) speed)
vy (* (/ dy h) speed)
bullet {:sprite "/sprite/projectile.png" ; TODO: Render sprite based on player weapon
:x x
:y y
:velocity {:x vx :y vy}}]
(s/change state-chan update :entities assoc bullet-id bullet)))

10
src/il/json.clj Normal file
View File

@@ -0,0 +1,10 @@
(ns il.json
(:require [jsonista.core :as j]))
(def keyword-mapper (j/object-mapper {:decode-key-fn true}))
(defn from-json [str]
(j/read-value str keyword-mapper))
(defn to-json [data]
(j/write-value-as-string data))

5
src/il/state.clj Normal file
View File

@@ -0,0 +1,5 @@
(ns il.state
(:require [clojure.core.async :refer [>! go]]))
(defn change [state-chan & args]
(go (>! state-chan args)))