Initial commit

This commit is contained in:
Jacob VanDomelen 2026-01-19 10:33:24 -08:00
commit ae221e29bc
No known key found for this signature in database
24 changed files with 1034 additions and 0 deletions

View file

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(lein run)",
"Bash(lein check:*)",
"WebFetch(domain:github.com)"
]
}
}

4
.direnv/CACHEDIR.TAG Normal file
View file

@ -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/

1
.direnv/flake-profile Symbolic link
View file

@ -0,0 +1 @@
flake-profile-3-link

View file

@ -0,0 +1 @@
/nix/store/dm4m8lnm28n4iwzqcs4qh011kdla3dm0-env-env

2
.envrc Normal file
View file

@ -0,0 +1,2 @@
use flake
layout node

4
.lein-repl-history Normal file
View file

@ -0,0 +1,4 @@
(require '[lanterna.screen :as scr])
(keys (ns-publics 'lanterna.screen))
(require '[lanterna.terminal :as term])
(keys (ns-publics 'lanterna.terminal))

135
README.md Normal file
View file

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

61
flake.lock generated Normal file
View file

@ -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
}

37
flake.nix Normal file
View file

@ -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 = ''
'';
};
}
);
}

11
project.clj Normal file
View file

@ -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"]}})

49
src/knife_fight/ai.clj Normal file
View file

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

60
src/knife_fight/cards.clj Normal file
View file

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

View file

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

118
src/knife_fight/core.clj Normal file
View file

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

43
src/knife_fight/deck.clj Normal file
View file

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

64
src/knife_fight/game.clj Normal file
View file

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

95
src/knife_fight/rules.clj Normal file
View file

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

View file

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

120
src/knife_fight/ui/menu.clj Normal file
View file

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

View file

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

View file

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

View file

@ -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}}}]

View file

@ -0,0 +1,3 @@
artifactId=knife-fight
groupId=knife-fight
version=0.1.0-SNAPSHOT

View file

@ -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}}}]