From ae221e29bc9bfcad1a544a090d1b0268f8f3151a Mon Sep 17 00:00:00 2001 From: Jacob VanDomelen Date: Mon, 19 Jan 2026 10:33:24 -0800 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 10 ++ .direnv/CACHEDIR.TAG | 4 + .direnv/flake-profile | 1 + .direnv/flake-profile-3-link | 1 + .envrc | 2 + .lein-repl-history | 4 + README.md | 135 ++++++++++++++++++ flake.lock | 61 ++++++++ flake.nix | 37 +++++ project.clj | 11 ++ src/knife_fight/ai.clj | 49 +++++++ src/knife_fight/cards.clj | 60 ++++++++ src/knife_fight/config.clj | 28 ++++ src/knife_fight/core.clj | 118 +++++++++++++++ src/knife_fight/deck.clj | 43 ++++++ src/knife_fight/game.clj | 64 +++++++++ src/knife_fight/rules.clj | 95 ++++++++++++ src/knife_fight/ui/input.clj | 20 +++ src/knife_fight/ui/menu.clj | 120 ++++++++++++++++ src/knife_fight/ui/render.clj | 108 ++++++++++++++ src/knife_fight/ui/screen.clj | 58 ++++++++ ...core.classpath.extract-native-dependencies | 1 + .../knife-fight/knife-fight/pom.properties | 3 + ...core.classpath.extract-native-dependencies | 1 + 24 files changed, 1034 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .direnv/CACHEDIR.TAG create mode 120000 .direnv/flake-profile create mode 120000 .direnv/flake-profile-3-link create mode 100644 .envrc create mode 100644 .lein-repl-history create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 project.clj create mode 100644 src/knife_fight/ai.clj create mode 100644 src/knife_fight/cards.clj create mode 100644 src/knife_fight/config.clj create mode 100644 src/knife_fight/core.clj create mode 100644 src/knife_fight/deck.clj create mode 100644 src/knife_fight/game.clj create mode 100644 src/knife_fight/rules.clj create mode 100644 src/knife_fight/ui/input.clj create mode 100644 src/knife_fight/ui/menu.clj create mode 100644 src/knife_fight/ui/render.clj create mode 100644 src/knife_fight/ui/screen.clj create mode 100644 target/check/stale/leiningen.core.classpath.extract-native-dependencies create mode 100644 target/classes/META-INF/maven/knife-fight/knife-fight/pom.properties create mode 100644 target/stale/leiningen.core.classpath.extract-native-dependencies diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..62663a4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(lein run)", + "Bash(lein check:*)", + "WebFetch(domain:github.com)" + ] + } +} diff --git a/.direnv/CACHEDIR.TAG b/.direnv/CACHEDIR.TAG new file mode 100644 index 0000000..d886487 --- /dev/null +++ b/.direnv/CACHEDIR.TAG @@ -0,0 +1,4 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by direnv. +# For information about cache directory tags, see: +# http://www.brynosaurus.com/cachedir/ diff --git a/.direnv/flake-profile b/.direnv/flake-profile new file mode 120000 index 0000000..519b17b --- /dev/null +++ b/.direnv/flake-profile @@ -0,0 +1 @@ +flake-profile-3-link \ No newline at end of file diff --git a/.direnv/flake-profile-3-link b/.direnv/flake-profile-3-link new file mode 120000 index 0000000..688ab76 --- /dev/null +++ b/.direnv/flake-profile-3-link @@ -0,0 +1 @@ +/nix/store/dm4m8lnm28n4iwzqcs4qh011kdla3dm0-env-env \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..d4b93ce --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +use flake +layout node diff --git a/.lein-repl-history b/.lein-repl-history new file mode 100644 index 0000000..7373713 --- /dev/null +++ b/.lein-repl-history @@ -0,0 +1,4 @@ +(require '[lanterna.screen :as scr]) +(keys (ns-publics 'lanterna.screen)) +(require '[lanterna.terminal :as term]) +(keys (ns-publics 'lanterna.terminal)) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3c7430 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Knife Fight + +A terminal-based card game where lower cards win and players bleed to death. Built with Clojure and Lanterna. + +## Game Rules + +### Objective +Force your opponent to bleed out (run out of deck cards). + +### How to Play + +1. **Setup**: Each player starts with a deck of cards (configurable, default is 2 suits = 27 cards) and draws 3 cards to their hand. + +2. **Each Round**: + - Both players play one card simultaneously + - **Lower card wins** (Ace=1 is the lowest, highest numbered cards lose) + - Winner's card goes to loser's damage pile + - Both players draw back to 3 cards + - **Bleeding**: Each player discards cards from their deck equal to (damage pile sum ÷ 3, rounded up) + +3. **Winning**: When a player runs out of deck cards (bleeds to death), they lose. + +### Face Card Abilities + +- **Jack (Parry)**: Nullifies the round completely - no damage dealt to either player +- **Queen (Field Dressing)**: Swap a card from your damage pile with opponent's played card (not yet implemented) +- **King (Feint)**: After cards are revealed, play a second card from your hand (not yet implemented) +- **Joker (Reflect)**: Reverse damage direction - opponent's card goes to their own damage pile + +### Deck Configuration + +You can configure the number of suits per player (1-4): +- **1 suit**: 14 cards per player (quick game) +- **2 suits**: 27 cards per player (standard game) [Default] +- **3 suits**: 40 cards per player (longer game) +- **4 suits**: 54 cards per player (maximum length game) + +Each suit includes: +- 13 numbered cards (Ace through King) +- 1 Joker + +## Installation & Running + +### Prerequisites +- Leiningen (already in your Nix flake) +- Clojure (already in your Nix flake) +- A terminal with at least 80x24 size + +### Running the Game + +```bash +cd /Users/tepichord/Projects/knife-fight +lein run +``` + +### Building a Standalone JAR + +```bash +lein uberjar +java -jar target/uberjar/knife-fight-0.1.0-SNAPSHOT-standalone.jar +``` + +## Game Controls + +### Main Menu +- `1` - Start a new game +- `2` - Configure deck size +- `3` - View help/rules +- `4` - Quit + +### During Gameplay +- `1`, `2`, `3` - Select card from your hand (numbered left to right) +- `Q` - Quit to main menu + +### Configuration Screen +- `1`, `2`, `3`, `4` - Select number of suits per player +- `Q` - Return to main menu + +## Game Architecture + +The game follows functional programming principles with immutable data structures: + +- **cards.clj**: Card data structures and operations +- **deck.clj**: Deck generation and manipulation (configurable sizes) +- **game.clj**: Game state management +- **config.clj**: Configuration system for deck sizes +- **rules.clj**: Game mechanics (damage, bleeding, abilities) +- **ai.clj**: AI opponent strategy +- **ui/**: Terminal UI layer + - **screen.clj**: Lanterna screen management + - **menu.clj**: Main menu and configuration screens + - **render.clj**: Game state rendering + - **input.clj**: Keyboard input handling +- **core.clj**: Main menu loop and game orchestration + +## AI Strategy + +The AI uses a scoring system to select cards: +- Prioritizes medium-value cards (4-7) for consistent wins +- Saves very low cards (Ace, 2, 3) for critical moments +- Uses Jokers strategically when opponent has high damage (>15) +- Adapts strategy based on hand size and opponent's damage + +## Development + +### REPL +```bash +lein repl +``` + +### Checking Code +```bash +lein check +``` + +## Known Limitations + +- Queen (Field Dressing) ability requires UI selection - simplified in current version +- King (Feint) ability requires playing a second card - not yet implemented +- No save/load functionality +- No multiplayer support (only Human vs AI) + +## Future Enhancements + +- Full implementation of Queen and King abilities +- Save/load game state +- Statistics tracking +- Multiple AI difficulty levels +- Tournament mode (best of N games) +- Network multiplayer +- Replay system + +## License + +EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6d0ac08 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767892417, + "narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..29000be --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + description = "A personal website configuration"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + in + { + devShells.default = pkgs.mkShell { + name = "env"; + buildInputs = with pkgs; [ + helix + zellij + jujutsu + _1password-cli + gh + direnv + watch + leiningen + clojure + ]; + + shellHook = '' + ''; + }; + } + ); +} diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..269822e --- /dev/null +++ b/project.clj @@ -0,0 +1,11 @@ +(defproject knife-fight "0.1.0-SNAPSHOT" + :description "A two-player terminal-based card game where lower cards win and players bleed to death" + :url "https://github.com/tepichord/knife-fight" + :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/"} + :dependencies [[org.clojure/clojure "1.11.1"] + [clojure-lanterna "0.9.7"]] + :main ^:skip-aot knife-fight.core + :target-path "target/%s" + :profiles {:uberjar {:aot :all + :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) diff --git a/src/knife_fight/ai.clj b/src/knife_fight/ai.clj new file mode 100644 index 0000000..d54b7ae --- /dev/null +++ b/src/knife_fight/ai.clj @@ -0,0 +1,49 @@ +(ns knife-fight.ai + (:require [knife-fight.cards :as cards] + [knife-fight.game :as game])) + +(defn score-card-play + "Scores a potential card play based on strategic value" + [card opponent-damage-sum hand-size] + (let [base-value (cards/card-value card) + card-type (cards/card-type card)] + (cond + ;; Joker is powerful, save for high-damage situations + (= card-type :reflect) + (if (> opponent-damage-sum 15) 100 20) + + ;; Jack is defensive, good when we might lose + (= card-type :parry) + 50 + + ;; Queen allows damage manipulation + (= card-type :field-dressing) + 60 + + ;; King doubles damage effectiveness + (= card-type :feint) + 70 + + ;; For number cards, prefer lower values (to win rounds) + ;; But save very low cards (1-3) for critical moments + :else + (cond + (<= base-value 3) (if (< hand-size 2) 80 30) + (<= base-value 7) 90 ; Medium cards are safe plays + :else 40)))) ; High cards are risky + +(defn select-card + "AI selects the best card to play from hand" + [game-state] + (let [ai-hand (get-in game-state [:players :ai :hand]) + human-damage (game/damage-pile-sum game-state :human) + hand-size (count ai-hand)] + + ;; Score each card and select the highest scoring one + (->> ai-hand + (map (fn [card] + {:card card + :score (score-card-play card human-damage hand-size)})) + (sort-by :score >) + first + :card))) diff --git a/src/knife_fight/cards.clj b/src/knife_fight/cards.clj new file mode 100644 index 0000000..a280e3a --- /dev/null +++ b/src/knife_fight/cards.clj @@ -0,0 +1,60 @@ +(ns knife-fight.cards) + +(def ranks + "Card ranks with their numeric values for comparison" + {:ace 1, :two 2, :three 3, :four 4, :five 5, :six 6, :seven 7, + :eight 8, :nine 9, :ten 10, :jack 11, :queen 12, :king 13, :joker 14}) + +(def face-cards #{:jack :queen :king :joker}) + +(defn create-card + "Creates a card map with suit and rank" + [suit rank] + {:suit suit + :rank rank + :value (get ranks rank) + :face? (contains? face-cards rank)}) + +(defn card-value + "Returns the numeric value of a card for comparison" + [card] + (:value card)) + +(defn face-card? + "Returns true if the card is a face card" + [card] + (:face? card)) + +(defn card-type + "Returns the type/ability of the card" + [card] + (cond + (= (:rank card) :jack) :parry + (= (:rank card) :queen) :field-dressing + (= (:rank card) :king) :feint + (= (:rank card) :joker) :reflect + :else :number)) + +(defn card-display-name + "Returns a human-readable display name for a card" + [card] + (let [rank-str (case (:rank card) + :ace "A" + :two "2" + :three "3" + :four "4" + :five "5" + :six "6" + :seven "7" + :eight "8" + :nine "9" + :ten "10" + :jack "J" + :queen "Q" + :king "K" + :joker "JKR") + suit-symbol (case (:suit card) + :red "♥" + :black "♠" + "?")] + (str rank-str suit-symbol))) diff --git a/src/knife_fight/config.clj b/src/knife_fight/config.clj new file mode 100644 index 0000000..4bd9fcb --- /dev/null +++ b/src/knife_fight/config.clj @@ -0,0 +1,28 @@ +(ns knife-fight.config) + +(defonce config-state + (atom {:suits-per-player 2})) + +(defn get-suits-per-player + "Returns the current suits-per-player setting" + [] + (:suits-per-player @config-state)) + +(defn set-suits-per-player! + "Sets the suits-per-player configuration (1-4 allowed)" + [n] + (when (and (integer? n) (<= 1 n 4)) + (swap! config-state assoc :suits-per-player n) + true)) + +(defn cards-per-player + "Calculates total cards per player based on suits-per-player" + ([] + (cards-per-player (get-suits-per-player))) + ([suits] + (* suits 14))) ; 13 numbered cards + 1 joker per suit + +(defn get-config + "Returns the current configuration map" + [] + @config-state) diff --git a/src/knife_fight/core.clj b/src/knife_fight/core.clj new file mode 100644 index 0000000..41c2231 --- /dev/null +++ b/src/knife_fight/core.clj @@ -0,0 +1,118 @@ +(ns knife-fight.core + (:require [knife-fight.game :as game] + [knife-fight.rules :as rules] + [knife-fight.ai :as ai] + [knife-fight.ui.screen :as screen] + [knife-fight.ui.render :as render] + [knife-fight.ui.input :as input] + [knife-fight.ui.menu :as menu]) + (:gen-class)) + +(defn play-card + "Plays a card from player's hand" + [game-state player-key card-index] + (let [hand (get-in game-state [:players player-key :hand]) + card (nth hand card-index nil)] + (when card + (-> game-state + (assoc-in [:current-round + (if (= player-key :human) :human-card :ai-card)] + card) + (update-in [:players player-key :hand] + (fn [h] (vec (concat (take card-index h) + (drop (inc card-index) h))))))))) + +(defn process-turn + "Processes a single turn with both players' actions" + [game-state human-card-index] + (let [;; Human plays card + state-after-human (play-card game-state :human human-card-index) + + ;; AI selects and plays card + ai-card (ai/select-card state-after-human) + ai-hand (get-in state-after-human [:players :ai :hand]) + ai-card-index (->> ai-hand + (map-indexed vector) + (filter #(= (second %) ai-card)) + first + first) + state-after-ai (play-card state-after-human :ai ai-card-index) + + ;; Resolve the round + human-card (get-in state-after-ai [:current-round :human-card]) + ai-card-played (get-in state-after-ai [:current-round :ai-card]) + state-after-resolution (rules/resolve-round state-after-ai + human-card + ai-card-played) + + ;; End round (draw and bleed) + final-state (rules/end-round-phase state-after-resolution)] + + final-state)) + +(defn game-loop + "Main game loop" + [] + (loop [game-state (game/create-initial-state)] + + ;; Render current state + (render/render-game-state game-state) + + ;; Check for game over + (if (game/game-over? game-state) + (do + (render/render-game-over + (assoc game-state :winner (game/determine-winner game-state))) + :game-over) + + ;; Get player input + (let [input-result (input/get-player-input)] + (case (:action input-result) + :quit :quit + + :select + (let [card-index (:index input-result) + hand-size (game/hand-size game-state :human)] + (if (< card-index hand-size) + ;; Valid card selection - process turn + (recur (process-turn game-state card-index)) + ;; Invalid selection - stay in same state + (recur game-state))) + + ;; No action or unknown action + (recur game-state)))))) + +(defn menu-loop + "Main menu loop" + [] + (loop [] + (let [action (menu/handle-main-menu)] + (case action + :new-game + (do + (game-loop) + (recur)) + + :configure + (do + (menu/handle-config-menu) + (recur)) + + :help + (do + (menu/render-help) + (recur)) + + :quit + :quit + + (recur))))) + +(defn -main + "Entry point for the game" + [& args] + (try + (screen/init-screen!) + (menu-loop) + (finally + (screen/stop-screen!)))) diff --git a/src/knife_fight/deck.clj b/src/knife_fight/deck.clj new file mode 100644 index 0000000..5699984 --- /dev/null +++ b/src/knife_fight/deck.clj @@ -0,0 +1,43 @@ +(ns knife-fight.deck + (:require [knife-fight.cards :as cards] + [knife-fight.config :as config])) + +(def all-ranks [:ace :two :three :four :five :six :seven :eight :nine :ten :jack :queen :king]) + +(defn create-suit + "Creates a single suit of 13 cards + 1 joker for the given color" + [color] + (let [numbered-cards (mapv #(cards/create-card color %) all-ranks) + joker (cards/create-card color :joker)] + (conj numbered-cards joker))) + +(defn create-decks + "Creates red and black decks based on the number of suits per player" + [suits-per-player] + (let [red-cards (vec (apply concat (repeatedly suits-per-player #(create-suit :red)))) + black-cards (vec (apply concat (repeatedly suits-per-player #(create-suit :black))))] + {:red (shuffle red-cards) + :black (shuffle black-cards)})) + +(defn create-default-decks + "Creates decks using the current configuration" + [] + (create-decks (config/get-suits-per-player))) + +(defn draw-card + "Draws a card from deck, returns [card remaining-deck]" + [deck] + (when (seq deck) + [(first deck) (vec (rest deck))])) + +(defn draw-cards + "Draws n cards from deck, returns [cards remaining-deck]" + [deck n] + (let [drawn (take n deck) + remaining (vec (drop n deck))] + [(vec drawn) remaining])) + +(defn deck-size + "Returns the number of cards in a deck" + [deck] + (count deck)) diff --git a/src/knife_fight/game.clj b/src/knife_fight/game.clj new file mode 100644 index 0000000..2ad489d --- /dev/null +++ b/src/knife_fight/game.clj @@ -0,0 +1,64 @@ +(ns knife-fight.game + (:require [knife-fight.deck :as deck] + [knife-fight.cards :as cards])) + +(defn create-initial-state + "Creates a new game with shuffled and split decks" + [] + (let [{:keys [red black]} (deck/create-default-decks) + [human-hand human-deck] (deck/draw-cards red 3) + [ai-hand ai-deck] (deck/draw-cards black 3)] + {:players {:human {:hand human-hand + :deck human-deck + :damage-pile [] + :name "Player"} + :ai {:hand ai-hand + :deck ai-deck + :damage-pile [] + :name "Computer"}} + :current-round {:human-card nil + :ai-card nil + :winner nil} + :round-number 1 + :game-over? false + :winner nil})) + +(defn get-player + "Gets a player's state from game state" + [game-state player-key] + (get-in game-state [:players player-key])) + +(defn update-player + "Updates a player's state in the game" + [game-state player-key update-fn] + (update-in game-state [:players player-key] update-fn)) + +(defn hand-size + "Returns the number of cards in a player's hand" + [game-state player-key] + (count (get-in game-state [:players player-key :hand]))) + +(defn deck-size + "Returns the number of cards in a player's deck" + [game-state player-key] + (count (get-in game-state [:players player-key :deck]))) + +(defn damage-pile-sum + "Calculates total damage for a player" + [game-state player-key] + (reduce + 0 (map cards/card-value + (get-in game-state [:players player-key :damage-pile])))) + +(defn game-over? + "Checks if game is over (a player has no deck cards left)" + [game-state] + (or (zero? (deck-size game-state :human)) + (zero? (deck-size game-state :ai)))) + +(defn determine-winner + "Determines the winner when game is over" + [game-state] + (cond + (zero? (deck-size game-state :human)) :ai + (zero? (deck-size game-state :ai)) :human + :else nil)) diff --git a/src/knife_fight/rules.clj b/src/knife_fight/rules.clj new file mode 100644 index 0000000..6c8ac5b --- /dev/null +++ b/src/knife_fight/rules.clj @@ -0,0 +1,95 @@ +(ns knife-fight.rules + (:require [knife-fight.game :as game] + [knife-fight.cards :as cards] + [knife-fight.deck :as deck])) + +(defn compare-cards + "Compares two cards, returns :human/:ai/:tie. Lower value wins." + [human-card ai-card] + (let [human-val (cards/card-value human-card) + ai-val (cards/card-value ai-card)] + (cond + (< human-val ai-val) :human + (> human-val ai-val) :ai + :else :tie))) + +(defn resolve-round + "Resolves a round: compare cards, apply abilities, deal damage" + [game-state human-card ai-card] + (let [human-type (cards/card-type human-card) + ai-type (cards/card-type ai-card) + winner (compare-cards human-card ai-card)] + + (cond + ;; Both Jacks = double parry, no damage + (and (= human-type :parry) (= ai-type :parry)) + (assoc-in game-state [:current-round :winner] :tie) + + ;; One Jack = parry, no damage + (or (= human-type :parry) (= ai-type :parry)) + (assoc-in game-state [:current-round :winner] :tie) + + ;; Joker reflects damage back + (= human-type :reflect) + (-> game-state + (game/update-player :human #(update % :damage-pile conj ai-card)) + (assoc-in [:current-round :winner] :human)) + + (= ai-type :reflect) + (-> game-state + (game/update-player :ai #(update % :damage-pile conj human-card)) + (assoc-in [:current-round :winner] :ai)) + + ;; Tie: both take damage + (= winner :tie) + (-> game-state + (game/update-player :human #(update % :damage-pile conj ai-card)) + (game/update-player :ai #(update % :damage-pile conj human-card)) + (assoc-in [:current-round :winner] :tie)) + + ;; Normal damage: winner's card goes to loser's damage pile + :else + (let [loser (if (= winner :human) :ai :human) + damage-card (if (= winner :human) human-card ai-card)] + (-> game-state + (game/update-player loser #(update % :damage-pile conj damage-card)) + (assoc-in [:current-round :winner] winner)))))) + +(defn draw-to-hand + "Draws cards until player has 3 cards in hand" + [game-state player-key] + (let [current-hand-size (game/hand-size game-state player-key) + cards-needed (- 3 current-hand-size) + player-deck (get-in game-state [:players player-key :deck])] + (if (and (pos? cards-needed) (>= (count player-deck) cards-needed)) + (let [[drawn remaining] (deck/draw-cards player-deck cards-needed)] + (-> game-state + (update-in [:players player-key :hand] concat drawn) + (assoc-in [:players player-key :deck] remaining))) + game-state))) + +(defn calculate-bleed + "Calculates bleed amount: damage pile sum ÷ 3, rounded up" + [damage-pile] + (let [total-damage (reduce + 0 (map cards/card-value damage-pile))] + (int (Math/ceil (/ total-damage 3.0))))) + +(defn apply-bleed + "Applies bleed damage: discard cards from deck" + [game-state player-key] + (let [bleed-amount (calculate-bleed + (get-in game-state [:players player-key :damage-pile])) + current-deck (get-in game-state [:players player-key :deck]) + cards-to-bleed (min bleed-amount (count current-deck)) + remaining-deck (vec (drop cards-to-bleed current-deck))] + (assoc-in game-state [:players player-key :deck] remaining-deck))) + +(defn end-round-phase + "Completes end of round: draw to 3, then bleed both players" + [game-state] + (-> game-state + (draw-to-hand :human) + (draw-to-hand :ai) + (apply-bleed :human) + (apply-bleed :ai) + (update :round-number inc))) diff --git a/src/knife_fight/ui/input.clj b/src/knife_fight/ui/input.clj new file mode 100644 index 0000000..4805051 --- /dev/null +++ b/src/knife_fight/ui/input.clj @@ -0,0 +1,20 @@ +(ns knife-fight.ui.input + (:require [knife-fight.ui.screen :as screen])) + +(defn parse-input + "Parses keyboard input into game actions" + [key-input] + (case key-input + \1 {:action :select :index 0} + \2 {:action :select :index 1} + \3 {:action :select :index 2} + \4 {:action :select :index 3} + \q {:action :quit} + \Q {:action :quit} + nil)) + +(defn get-player-input + "Gets and parses player input" + [] + (let [key (screen/get-key)] + (parse-input key))) diff --git a/src/knife_fight/ui/menu.clj b/src/knife_fight/ui/menu.clj new file mode 100644 index 0000000..e350437 --- /dev/null +++ b/src/knife_fight/ui/menu.clj @@ -0,0 +1,120 @@ +(ns knife-fight.ui.menu + (:require [knife-fight.ui.screen :as screen] + [knife-fight.ui.input :as input] + [knife-fight.config :as config])) + +(def title-color {:fg :red :styles #{:bold}}) +(def option-color {:fg :white}) +(def highlight-color {:fg :yellow :styles #{:bold}}) + +(defn render-main-menu + "Renders the main menu" + [] + (screen/clear-screen!) + (let [[width height] (screen/get-size) + center-x (/ width 2) + start-y (/ height 3)] + + ;; Title + (screen/put-string! (- center-x 10) start-y "=== KNIFE FIGHT ===" title-color) + + ;; Menu options + (screen/put-string! (- center-x 5) (+ start-y 2) "1. New Game" option-color) + (screen/put-string! (- center-x 10) (+ start-y 3) "2. Configure Deck Size" option-color) + (screen/put-string! (- center-x 6) (+ start-y 4) "3. Help / Rules" option-color) + (screen/put-string! (- center-x 3) (+ start-y 5) "4. Quit" option-color) + + (screen/put-string! (- center-x 15) (+ start-y 7) "Select an option (1-4)" highlight-color)) + + (screen/hide-cursor!) + (screen/redraw!)) + +(defn render-config-menu + "Renders the deck configuration menu" + [] + (screen/clear-screen!) + (let [[width height] (screen/get-size) + center-x (/ width 2) + start-y (/ height 4) + current-suits (config/get-suits-per-player)] + + ;; Title + (screen/put-string! (- center-x 12) start-y "=== CONFIGURE DECK SIZE ===" title-color) + + (screen/put-string! (- center-x 20) (+ start-y 2) + (str "Current setting: " current-suits " suits per player") + highlight-color) + + ;; Options + (screen/put-string! (- center-x 15) (+ start-y 4) + "1. 1 Suit (14 cards per player)" + (if (= current-suits 1) highlight-color option-color)) + (screen/put-string! (- center-x 15) (+ start-y 5) + "2. 2 Suits (27 cards per player) [Default]" + (if (= current-suits 2) highlight-color option-color)) + (screen/put-string! (- center-x 15) (+ start-y 6) + "3. 3 Suits (40 cards per player)" + (if (= current-suits 3) highlight-color option-color)) + (screen/put-string! (- center-x 15) (+ start-y 7) + "4. 4 Suits (54 cards per player)" + (if (= current-suits 4) highlight-color option-color)) + + (screen/put-string! (- center-x 20) (+ start-y 9) + "Select deck size (1-4) or Q to return" + option-color)) + + (screen/hide-cursor!) + (screen/redraw!)) + +(defn render-help + "Renders the help/rules screen" + [] + (screen/clear-screen!) + + (screen/put-string! 2 1 "=== KNIFE FIGHT RULES ===" title-color) + + (screen/put-string! 2 3 "GOAL: Force your opponent to bleed out (run out of deck cards)") + (screen/put-string! 2 5 "HOW TO PLAY:") + (screen/put-string! 2 6 "1. Each round, both players play one card") + (screen/put-string! 2 7 "2. LOWER card wins the round (Ace=1 is lowest)") + (screen/put-string! 2 8 "3. Winner's card goes to loser's damage pile") + (screen/put-string! 2 9 "4. Draw back to 3 cards") + (screen/put-string! 2 10 "5. BLEED: Discard cards from deck = (damage sum / 3, rounded up)") + + (screen/put-string! 2 12 "FACE CARDS:" highlight-color) + (screen/put-string! 2 13 "Jack (Parry): Nullifies round, no damage" option-color) + (screen/put-string! 2 14 "Queen (Field Dress): Swap damage pile card" option-color) + (screen/put-string! 2 15 "King (Feint): Play second card from hand" option-color) + (screen/put-string! 2 16 "Joker (Reflect): Reverse damage direction" option-color) + + (screen/put-string! 2 18 "Press any key to return to menu..." highlight-color) + + (screen/hide-cursor!) + (screen/redraw!) + (screen/get-key)) + +(defn handle-main-menu + "Handles main menu input and returns action" + [] + (render-main-menu) + (let [input (input/get-player-input)] + (case (:index input) + 0 :new-game + 1 :configure + 2 :help + 3 :quit + (if (= (:action input) :quit) + :quit + (recur))))) + +(defn handle-config-menu + "Handles configuration menu input" + [] + (render-config-menu) + (let [input (input/get-player-input)] + (if (= (:action input) :quit) + :menu + (do + (when-let [idx (:index input)] + (config/set-suits-per-player! (inc idx))) + (recur))))) diff --git a/src/knife_fight/ui/render.clj b/src/knife_fight/ui/render.clj new file mode 100644 index 0000000..3812d6a --- /dev/null +++ b/src/knife_fight/ui/render.clj @@ -0,0 +1,108 @@ +(ns knife-fight.ui.render + (:require [knife-fight.ui.screen :as screen] + [knife-fight.game :as game] + [knife-fight.cards :as cards] + [knife-fight.rules :as rules])) + +;; Color schemes +(def colors + {:red {:fg :red} + :black {:fg :white} + :highlight {:fg :yellow :styles #{:bold}} + :damage {:fg :red :styles #{:bold}} + :info {:fg :cyan} + :title {:fg :white :styles #{:bold}}}) + +(defn render-hand + "Renders a player's hand at specified position" + [x y hand player-name] + (screen/put-string! x y (str player-name "'s Hand:") (:title colors)) + (doseq [[idx card] (map-indexed vector hand)] + (let [card-str (str (inc idx) ". " (cards/card-display-name card)) + style (if (= :red (:suit card)) (:red colors) (:black colors))] + (screen/put-string! x (+ y 1 idx) card-str style)))) + +(defn render-deck-info + "Renders deck and damage pile information" + [x y player-key game-state] + (let [deck-count (game/deck-size game-state player-key) + damage-sum (game/damage-pile-sum game-state player-key) + damage-cards (get-in game-state [:players player-key :damage-pile]) + bleed (rules/calculate-bleed damage-cards)] + (screen/put-string! x y (str "Deck: " deck-count " cards") (:info colors)) + (screen/put-string! x (inc y) + (str "Damage: " damage-sum " (Bleed: " bleed ")") + (:damage colors)) + (screen/put-string! x (+ y 2) "Damage Pile:") + (doseq [[idx card] (map-indexed vector (take 5 damage-cards))] + (screen/put-string! x (+ y 3 idx) (str " " (cards/card-display-name card)))))) + +(defn render-round-result + "Renders the result of the current round" + [x y game-state] + (let [human-card (get-in game-state [:current-round :human-card]) + ai-card (get-in game-state [:current-round :ai-card]) + winner (get-in game-state [:current-round :winner])] + (when (and human-card ai-card) + (screen/put-string! x y "Last Round:" (:title colors)) + (screen/put-string! x (inc y) + (str "You played: " (cards/card-display-name human-card))) + (screen/put-string! x (+ y 2) + (str "AI played: " (cards/card-display-name ai-card))) + (screen/put-string! x (+ y 3) + (str "Winner: " (case winner + :human "You!" + :ai "Computer" + :tie "Tie (no damage)")) + (:highlight colors))))) + +(defn render-game-state + "Main rendering function - renders entire game state" + [game-state] + (screen/clear-screen!) + + (let [human-hand (get-in game-state [:players :human :hand]) + ai-hand-count (count (get-in game-state [:players :ai :hand]))] + + ;; Title + (screen/put-string! 2 1 "=== KNIFE FIGHT ===" (:title colors)) + (screen/put-string! 2 2 + (str "Round " (:round-number game-state)) + (:info colors)) + + ;; AI player area (top) + (screen/put-string! 2 4 "Computer's Hand:" (:title colors)) + (screen/put-string! 2 5 (str ai-hand-count " cards")) + (render-deck-info 40 4 :ai game-state) + + ;; Round result if available + (render-round-result 2 11 game-state) + + ;; Human player area (bottom) + (render-hand 2 18 human-hand "Player") + (render-deck-info 40 18 :human game-state) + + ;; Instructions + (screen/put-string! 2 22 + "Use 1-3 to select card, Q to quit to menu" + (:info colors))) + + (screen/hide-cursor!) + (screen/redraw!)) + +(defn render-game-over + "Renders game over screen" + [game-state] + (screen/clear-screen!) + (let [[width height] (screen/get-size) + winner (:winner game-state) + winner-name (if (= winner :human) "PLAYER" "COMPUTER")] + (screen/put-string! (- (/ width 2) 10) (/ height 2) + (str "GAME OVER - " winner-name " WINS!") + (:title colors)) + (screen/put-string! (- (/ width 2) 15) (inc (/ height 2)) + "Press any key to return to menu" + (:info colors))) + (screen/hide-cursor!) + (screen/redraw!) + (screen/get-key)) diff --git a/src/knife_fight/ui/screen.clj b/src/knife_fight/ui/screen.clj new file mode 100644 index 0000000..3939851 --- /dev/null +++ b/src/knife_fight/ui/screen.clj @@ -0,0 +1,58 @@ +(ns knife-fight.ui.screen + (:require [lanterna.screen :as scr])) + +(defonce screen-state (atom nil)) + +(defn init-screen! + "Initializes the Lanterna screen" + [] + (let [screen (scr/get-screen :text)] + (scr/start screen) + (reset! screen-state screen) + screen)) + +(defn stop-screen! + "Stops and cleans up the screen" + [] + (when @screen-state + (scr/stop @screen-state) + (reset! screen-state nil))) + +(defn hide-cursor! + "Hides the terminal cursor by setting cursor position to null" + [] + (when @screen-state + (.setCursorPosition @screen-state nil))) + +(defn clear-screen! + "Clears the screen" + [] + (when @screen-state + (scr/clear @screen-state))) + +(defn redraw! + "Redraws the screen" + [] + (when @screen-state + (scr/redraw @screen-state))) + +(defn get-key + "Gets a key press (blocking)" + [] + (when @screen-state + (scr/get-key-blocking @screen-state))) + +(defn put-string! + "Puts a string at specified coordinates with optional styling" + ([x y text] + (when @screen-state + (scr/put-string @screen-state x y text))) + ([x y text styles] + (when @screen-state + (scr/put-string @screen-state x y text styles)))) + +(defn get-size + "Returns [width height] of terminal" + [] + (when @screen-state + (scr/get-size @screen-state))) diff --git a/target/check/stale/leiningen.core.classpath.extract-native-dependencies b/target/check/stale/leiningen.core.classpath.extract-native-dependencies new file mode 100644 index 0000000..81709eb --- /dev/null +++ b/target/check/stale/leiningen.core.classpath.extract-native-dependencies @@ -0,0 +1 @@ +[{:dependencies {org.clojure/clojure {:vsn "1.11.1", :native-prefix nil}, org.clojure/spec.alpha {:vsn "0.3.218", :native-prefix nil}, org.clojure/core.specs.alpha {:vsn "0.2.62", :native-prefix nil}, clojure-lanterna {:vsn "0.9.7", :native-prefix nil}, com.googlecode.lanterna/lanterna {:vsn "2.1.7", :native-prefix nil}, nrepl {:vsn "1.0.0", :native-prefix nil}, org.nrepl/incomplete {:vsn "0.1.0", :native-prefix nil}}, :native-path "target/native"} {:native-path "target/native", :dependencies {org.clojure/clojure {:vsn "1.11.1", :native-prefix nil, :native? false}, org.clojure/spec.alpha {:vsn "0.3.218", :native-prefix nil, :native? false}, org.clojure/core.specs.alpha {:vsn "0.2.62", :native-prefix nil, :native? false}, clojure-lanterna {:vsn "0.9.7", :native-prefix nil, :native? false}, com.googlecode.lanterna/lanterna {:vsn "2.1.7", :native-prefix nil, :native? false}, nrepl {:vsn "1.0.0", :native-prefix nil, :native? false}, org.nrepl/incomplete {:vsn "0.1.0", :native-prefix nil, :native? false}}}] \ No newline at end of file diff --git a/target/classes/META-INF/maven/knife-fight/knife-fight/pom.properties b/target/classes/META-INF/maven/knife-fight/knife-fight/pom.properties new file mode 100644 index 0000000..4e53f80 --- /dev/null +++ b/target/classes/META-INF/maven/knife-fight/knife-fight/pom.properties @@ -0,0 +1,3 @@ +artifactId=knife-fight +groupId=knife-fight +version=0.1.0-SNAPSHOT diff --git a/target/stale/leiningen.core.classpath.extract-native-dependencies b/target/stale/leiningen.core.classpath.extract-native-dependencies new file mode 100644 index 0000000..81709eb --- /dev/null +++ b/target/stale/leiningen.core.classpath.extract-native-dependencies @@ -0,0 +1 @@ +[{:dependencies {org.clojure/clojure {:vsn "1.11.1", :native-prefix nil}, org.clojure/spec.alpha {:vsn "0.3.218", :native-prefix nil}, org.clojure/core.specs.alpha {:vsn "0.2.62", :native-prefix nil}, clojure-lanterna {:vsn "0.9.7", :native-prefix nil}, com.googlecode.lanterna/lanterna {:vsn "2.1.7", :native-prefix nil}, nrepl {:vsn "1.0.0", :native-prefix nil}, org.nrepl/incomplete {:vsn "0.1.0", :native-prefix nil}}, :native-path "target/native"} {:native-path "target/native", :dependencies {org.clojure/clojure {:vsn "1.11.1", :native-prefix nil, :native? false}, org.clojure/spec.alpha {:vsn "0.3.218", :native-prefix nil, :native? false}, org.clojure/core.specs.alpha {:vsn "0.2.62", :native-prefix nil, :native? false}, clojure-lanterna {:vsn "0.9.7", :native-prefix nil, :native? false}, com.googlecode.lanterna/lanterna {:vsn "2.1.7", :native-prefix nil, :native? false}, nrepl {:vsn "1.0.0", :native-prefix nil, :native? false}, org.nrepl/incomplete {:vsn "0.1.0", :native-prefix nil, :native? false}}}] \ No newline at end of file