Initial commit
This commit is contained in:
commit
ae221e29bc
24 changed files with 1034 additions and 0 deletions
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(lein run)",
|
||||
"Bash(lein check:*)",
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
.direnv/CACHEDIR.TAG
Normal file
4
.direnv/CACHEDIR.TAG
Normal 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
1
.direnv/flake-profile
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
flake-profile-3-link
|
||||
1
.direnv/flake-profile-3-link
Symbolic link
1
.direnv/flake-profile-3-link
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/nix/store/dm4m8lnm28n4iwzqcs4qh011kdla3dm0-env-env
|
||||
2
.envrc
Normal file
2
.envrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
use flake
|
||||
layout node
|
||||
4
.lein-repl-history
Normal file
4
.lein-repl-history
Normal 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
135
README.md
Normal 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
61
flake.lock
generated
Normal 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
37
flake.nix
Normal 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
11
project.clj
Normal 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
49
src/knife_fight/ai.clj
Normal 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
60
src/knife_fight/cards.clj
Normal 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)))
|
||||
28
src/knife_fight/config.clj
Normal file
28
src/knife_fight/config.clj
Normal 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
118
src/knife_fight/core.clj
Normal 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
43
src/knife_fight/deck.clj
Normal 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
64
src/knife_fight/game.clj
Normal 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
95
src/knife_fight/rules.clj
Normal 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)))
|
||||
20
src/knife_fight/ui/input.clj
Normal file
20
src/knife_fight/ui/input.clj
Normal 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
120
src/knife_fight/ui/menu.clj
Normal 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)))))
|
||||
108
src/knife_fight/ui/render.clj
Normal file
108
src/knife_fight/ui/render.clj
Normal 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))
|
||||
58
src/knife_fight/ui/screen.clj
Normal file
58
src/knife_fight/ui/screen.clj
Normal 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)))
|
||||
|
|
@ -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}}}]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
artifactId=knife-fight
|
||||
groupId=knife-fight
|
||||
version=0.1.0-SNAPSHOT
|
||||
|
|
@ -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}}}]
|
||||
Loading…
Add table
Reference in a new issue