Compare commits
17 Commits
9d4017100f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1893073af0 | ||
|
|
262292228e | ||
|
|
dee742b1e4 | ||
|
|
2706f7b25b | ||
|
|
7935983707 | ||
|
|
221cb71410 | ||
|
|
db4b5cb543 | ||
|
|
f431ae8c53 | ||
|
|
de7fa298b9 | ||
|
|
509fb35a20 | ||
|
|
56806178f3 | ||
|
|
56fba22217 | ||
|
|
54db0c8e6f | ||
|
|
731f2651a5 | ||
|
|
d7d69fa3d1 | ||
|
|
356957bd0f | ||
|
|
511b497580 |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.idea/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Pure stdlib. No dependencies, no build, no test/lint tooling.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- `main.py:main_game_loop()` — game start
|
||||
- `function_list.py:startGame()` — character creation (returns `name, gender, age, race, class_, pre_allocated_stats`)
|
||||
|
||||
## Play vs. debug
|
||||
|
||||
To skip character creation during playtesting, replace `startGame()` with a pre-generated character and assign stats directly (see `main.py:274-290` and `function_list.py:14-94` for the 3 pre-gens).
|
||||
|
||||
## Quirks
|
||||
|
||||
- `sys.path.append('.')` is required at the top of `main.py`, `scene.py`, `function_list.py` — sibling imports break without it.
|
||||
- `class` is a reserved keyword, so the project uses `class_` everywhere (parameter/attribute names, e.g. `Player.__init__(self, ..., class_)`).
|
||||
- Race/class modules export `RACES` and `CLASSES` dicts (e.g. `RACES["Elf"]`, `CLASSES["Wizard"]`). The underlying classes (`Race`, `CharacterClass`) are preserved for instantiation if needed.
|
||||
- `CharacterClass.__init__` receives `class_` as a parameter — do not confuse with Python's `class` statement.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `scene.py` — a directed graph of `Scene` objects linked via `add_exit(direction, scene)`. The `Scene` class also holds `available_items` (free pickups) and `lootable_items` (container contents). Containers use the `Container` class which has a `description`, `lock_dc` / `trap_dc` (DC for detecting locks/traps via an Intelligence check), and visible `contents`. Locked containers can't be opened (contents hidden); trapped containers trigger if opened without detection/disarming. Use `scene.add_corpse(enemy_name, items_dict)` to drop a lootable corpse when an enemy dies.
|
||||
- `player.py` — `Player`, `Inventory` (equipped slots, carried items, weight/encumbrance, currency), item hierarchy (`Item` → `Weapon`, `Armor`, `Money`).
|
||||
- `spell_list.py` — `Spell` objects grouped into `SPELL_LISTS[class_name][spell_level]`. Casting uses an SRD-style slot system with upcasting.
|
||||
- `enum_list.py` — shared enums (`Slots`, `DamageType`, `ItemType`, `SaveType`, etc.).
|
||||
- Modifier formula: `mod = -5 + stat // 2` (standard D&D 5e).
|
||||
@@ -1,340 +0,0 @@
|
||||
from functions import *
|
||||
from enum import Enum, auto
|
||||
from random import randint
|
||||
from scenes import entry
|
||||
|
||||
|
||||
class Player:
|
||||
"""Character Creation"""
|
||||
|
||||
# Default Character
|
||||
def __init__(self, name, gender, age, race, job):
|
||||
# Personality
|
||||
self.name = name
|
||||
self.gender = gender
|
||||
self.age = age
|
||||
self.race = race
|
||||
self.job = job
|
||||
# Health
|
||||
self.currentHP = 100
|
||||
self.maxHP = 100
|
||||
# Levels
|
||||
self.level = 1
|
||||
self.exp = 0
|
||||
self.maxEXP = 100
|
||||
# Magic
|
||||
self.spells_known = []
|
||||
self.spells_ready = []
|
||||
# Equipment
|
||||
self.weapon = rock
|
||||
self.armor = tornRags
|
||||
# Defence
|
||||
self.ac = 0
|
||||
self.elemental_resistance = {DamageType.FIRE: False, DamageType.WATER: False, DamageType.LIGHTNING: False}
|
||||
self.physical_resistance = {DamageType.SLASHING: False, DamageType.CRUSHING: False, DamageType.PIERCING: False}
|
||||
# Ability Scores
|
||||
self.stats = {"Strength": 0,
|
||||
"Dexterity": 0,
|
||||
"Constitution": 0,
|
||||
"Intelligence": 0,
|
||||
"Wisdom": 0,
|
||||
"Charisma": 0}
|
||||
# Ability Modifiers
|
||||
self.mods = {"Strength": 0,
|
||||
"Dexterity": 0,
|
||||
"Constitution": 0,
|
||||
"Intelligence": 0,
|
||||
"Wisdom": 0,
|
||||
"Charisma": 0}
|
||||
# Held items
|
||||
self.inventory = {rock: {"Count": 1,
|
||||
"object": Weapon,
|
||||
"equipped": True
|
||||
},
|
||||
tornRags: {"Count": 1,
|
||||
"object": Armor,
|
||||
"equipped": True
|
||||
},
|
||||
GP1: {"Count": 10,
|
||||
"object": Money,
|
||||
"equipped": False
|
||||
},
|
||||
}
|
||||
|
||||
# @property
|
||||
# def dexMod(self):
|
||||
# return self.mods["Dexterity"]
|
||||
|
||||
def getModifier(self):
|
||||
self.mods["Strength"] = -5 + self.stats["Strength"] // 2
|
||||
self.mods["Dexterity"] = -5 + self.stats["Dexterity"] // 2
|
||||
self.mods["Constitution"] = -5 + self.stats["Constitution"] // 2
|
||||
self.mods["Intelligence"] = -5 + self.stats["Intelligence"] // 2
|
||||
self.mods["Wisdom"] = -5 + self.stats["Wisdom"] // 2
|
||||
self.mods["Charisma"] = -5 + self.stats["Charisma"] // 2
|
||||
|
||||
def set_stats(self):
|
||||
# Race
|
||||
if self.race == "elf":
|
||||
self.stats["Dexterity"] += 2
|
||||
self.stats["Intelligence"] += 2
|
||||
elif self.race == "human":
|
||||
self.stats["Strength"] += 2
|
||||
self.stats["Constitution"] += 2
|
||||
elif self.race == "dwarf":
|
||||
self.stats["Constitution"] += 2
|
||||
self.stats["Strength"] += 2
|
||||
|
||||
# Job
|
||||
elif self.job == "warrior":
|
||||
self.stats["Strength"] += 2
|
||||
self.stats["Constitution"] += 1
|
||||
elif self.job == "ranger":
|
||||
self.stats["Dexterity"] += 2
|
||||
self.stats["Charisma"] += 1
|
||||
elif self.job == "mage":
|
||||
self.stats["Intelligence"] += 2
|
||||
self.stats["Charisma"] += 1
|
||||
|
||||
def introduce(self):
|
||||
typingPrint("Adjusting for your race and class bonuses." + "\n", time)
|
||||
time.sleep(1)
|
||||
typingPrint(f'Welcome {self.name}, to Kanjin: An RPG Text Adventure.\n', time)
|
||||
|
||||
def current_stats(self):
|
||||
"""Prints a display of the user's current statistics."""
|
||||
print("Your current stats are:")
|
||||
print(f'Hit Points: {self.currentHP}/{self.maxHP}')
|
||||
print(f'Strength: {self.stats["Strength"]}')
|
||||
print(f'Dexterity: {self.stats["Dexterity"]}')
|
||||
print(f'Constitution: {self.stats["Constitution"]}')
|
||||
print(f'Intelligence: {self.stats["Intelligence"]}')
|
||||
print(f'Charisma: {self.stats["Charisma"]}')
|
||||
|
||||
def current_equip(self):
|
||||
"""Prints a list of items currently equipped."""
|
||||
typingPrint(f"You have equipped:\n {self.armor.name}\n"
|
||||
f" {self.armor.description}"
|
||||
f"\n{self.weapon.name}\n"
|
||||
f" {self.weapon.description}\n", time)
|
||||
|
||||
def new_char(self):
|
||||
"""Outputs final stats, armour, and inventory"""
|
||||
typingPrint("We will now generate random stats for your character.\n", time)
|
||||
self.introduce()
|
||||
print(' ')
|
||||
time.sleep(2)
|
||||
self.rollStats()
|
||||
self.set_stats()
|
||||
print(' ')
|
||||
self.getModifier()
|
||||
self.current_stats()
|
||||
print(' ')
|
||||
time.sleep(2)
|
||||
self.current_equip()
|
||||
print(' ')
|
||||
current_inventory()
|
||||
print(' ')
|
||||
|
||||
def rollStats(self):
|
||||
"""Rolls stats 4d6kh3"""
|
||||
# Roll stats
|
||||
typingPrint("Rolling for Strength...\n", time)
|
||||
self.dice_rolls("Strength")
|
||||
typingPrint(f'Strength: {self.stats["Strength"]}\n', time)
|
||||
time.sleep(1)
|
||||
typingPrint("Rolling for Dexterity...\n", time)
|
||||
self.dice_rolls("Dexterity")
|
||||
typingPrint(f'Dexterity: {self.stats["Dexterity"]}\n', time)
|
||||
time.sleep(1)
|
||||
typingPrint("Rolling for Constitution...\n", time)
|
||||
self.dice_rolls("Constitution")
|
||||
typingPrint(f'Constitution: {self.stats["Constitution"]}\n', time)
|
||||
time.sleep(1)
|
||||
typingPrint("Rolling for Intelligence...\n", time)
|
||||
self.dice_rolls("Intelligence")
|
||||
typingPrint(f'Intelligence: {self.stats["Intelligence"]}\n', time)
|
||||
time.sleep(1)
|
||||
typingPrint("Rolling for Wisdom...\n", time)
|
||||
self.dice_rolls("Wisdom")
|
||||
typingPrint(f'Wisdom: {self.stats["Wisdom"]}\n', time)
|
||||
time.sleep(1)
|
||||
typingPrint("Rolling for Charisma...\n", time)
|
||||
self.dice_rolls("Charisma")
|
||||
typingPrint(f'Charisma: {self.stats["Charisma"]}\n', time)
|
||||
time.sleep(1)
|
||||
|
||||
def health_check(self):
|
||||
"""Print out current/max HP"""
|
||||
print(f'You have {self.currentHP}/{self.maxHP} HP.')
|
||||
|
||||
def take_damage(self, DMGtype, size):
|
||||
"""Define the damage type and dice size"""
|
||||
damage = randint(1, size)
|
||||
if DMGtype in self.elemental_resistance or DMGtype in self.physical_resistance:
|
||||
damage //= 2
|
||||
self.currentHP -= damage
|
||||
return damage
|
||||
|
||||
def dice_rolls(self, attribute):
|
||||
"""Rolling 4d6 to get stats for PC"""
|
||||
rolls = []
|
||||
# roll 4d6
|
||||
for roll in range(4):
|
||||
r = randint(1, 6)
|
||||
rolls.append(r)
|
||||
# Drop the lowest number
|
||||
del rolls[rolls.index(min(rolls))]
|
||||
self.stats[attribute] += sum(rolls)
|
||||
return attribute
|
||||
|
||||
|
||||
# Inventory list
|
||||
def current_inventory():
|
||||
"""Prints a list of items in your backpack that aren't equipped to your person."""
|
||||
typingPrint(f'In your rucksack you have:\n', time)
|
||||
for item_key, item_value in user.inventory.items():
|
||||
for key, value in item_value.items():
|
||||
if key == "equipped" and value == False:
|
||||
typingPrint(f'{item_key.name}\n'
|
||||
f' {item_key.description}\n', time)
|
||||
|
||||
|
||||
# Equipment list
|
||||
def equipped():
|
||||
"""Print currently equipped, print a list of held armor pieces,
|
||||
if input matches a held item, replace equipped condition"""
|
||||
|
||||
print(f"You currently have equipped: {user.armor}\n")
|
||||
print("Would you like to equip something new? Y/N")
|
||||
answer = input(">> ").lower().strip()
|
||||
if answer == "n" or answer == "no":
|
||||
pass
|
||||
if answer == "y" or answer == "yes":
|
||||
answer = input(
|
||||
"Which which item from your inventory would you like to equip?" + "\n" + ">> ").lower().strip()
|
||||
for item_key, item_value in user.inventory.items():
|
||||
# item_info = dict
|
||||
for key, value in item_value.items():
|
||||
# Leather armor[items], "object"[key], Armor[value]
|
||||
if key == "object" and value == Armor:
|
||||
print("\n", item_key)
|
||||
else:
|
||||
print("I'm sorry, I didn't understand that." + "\n" + "Would you like to equip something new? Y/N")
|
||||
|
||||
|
||||
class DamageType(Enum):
|
||||
SLASHING = auto()
|
||||
CRUSHING = auto()
|
||||
PIERCING = auto()
|
||||
FIRE = auto()
|
||||
WATER = auto()
|
||||
LIGHTNING = auto()
|
||||
|
||||
|
||||
class DamageMod(Enum):
|
||||
STRENGTH = auto()
|
||||
DEXTERITY = auto()
|
||||
CONSTITUTION = auto()
|
||||
INTELLIGENCE = auto()
|
||||
WISDOM = auto()
|
||||
CHARISMA = auto()
|
||||
|
||||
|
||||
class Item:
|
||||
"""The base class for all items"""
|
||||
|
||||
def __init__(self, name, description, value, magical):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.value = value
|
||||
self.magical = magical
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n"
|
||||
|
||||
|
||||
class Money(Item):
|
||||
"""The currency item used in the world of Kanjin"""
|
||||
|
||||
def __init__(self, name, amt, magical):
|
||||
self.name = name
|
||||
self.amt = amt
|
||||
self.magical = magical
|
||||
super().__init__(name=self.name,
|
||||
description=f"A small round coin made of {self.name.lower()} "
|
||||
f"with the imperial city logo stamped on the face.",
|
||||
value=self.amt,
|
||||
magical=self.magical)
|
||||
|
||||
|
||||
class Weapon(Item):
|
||||
"""The base class for all weapons"""
|
||||
|
||||
def __init__(self, name, description, value, damage1H, damage2H, dmgType, dmgMod, vers, thrown, magical):
|
||||
self.damage1H = damage1H
|
||||
self.damage2H = damage2H
|
||||
self.dmgType = dmgType
|
||||
self.dmgMod = dmgMod
|
||||
self.vers = vers
|
||||
self.thrown = thrown
|
||||
super().__init__(name, description, value, magical)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}" \
|
||||
f"\nDamage: One Handed - {self.damage1H}" \
|
||||
f"\nDamage: Two Handed - {self.damage2H}\n" \
|
||||
f"Damage Type: {self.dmgType}\nMagical: {self.magical}.\n"
|
||||
|
||||
|
||||
class Armor(Item):
|
||||
"""The base class for all armor"""
|
||||
|
||||
def __init__(self, name, description, value, grade, ac, disadvantage, dmgRes, magical):
|
||||
self.grade = grade
|
||||
self.ac = ac
|
||||
self.stealthDis = disadvantage
|
||||
self.dmgRes = dmgRes
|
||||
super().__init__(name, description, value, magical)
|
||||
|
||||
def __repr__(self):
|
||||
if self.stealthDis:
|
||||
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n" \
|
||||
f"AC: {self.ac}\nMagical: {self.magical}"
|
||||
elif not self.stealthDis:
|
||||
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n" \
|
||||
f"AC: {self.ac}\nDisadvantage on Stealth checks: {self.stealthDis}\nMagical: {self.magical}"
|
||||
|
||||
|
||||
# Different types of coins
|
||||
GP1 = Money("Gold", 1, False)
|
||||
SP1 = Money("Silver", 1, False)
|
||||
CP1 = Money("Copper", 1, False)
|
||||
|
||||
rock = Weapon(
|
||||
name="Rock",
|
||||
description="A fist-sized rock, suitable for bludgeoning.",
|
||||
value="No value",
|
||||
damage1H=randint(1, 6),
|
||||
damage2H=0,
|
||||
dmgType=DamageType.CRUSHING,
|
||||
dmgMod=DamageMod.STRENGTH,
|
||||
vers=False,
|
||||
thrown=True,
|
||||
magical=False
|
||||
)
|
||||
tornRags = Armor(
|
||||
name="Torn Rags",
|
||||
description="A ripped and worn-out outfit.",
|
||||
value=0,
|
||||
grade="Light",
|
||||
ac=0,
|
||||
disadvantage=True,
|
||||
dmgRes="None",
|
||||
magical=False
|
||||
)
|
||||
|
||||
# user = Player(*startGame())
|
||||
user = Player("Jamie", "Male", 29, "Elf", "Mage")
|
||||
# user.new_char()
|
||||
entry()
|
||||
31
README.md
31
README.md
@@ -1,13 +1,30 @@
|
||||
# Kanjin-Text-RPG
|
||||
An RPG text adventure game based of the Dungeons and Dragons SRD.
|
||||
|
||||
This game is being used as a learning experience for myself to practice code and learn by doing.
|
||||
It started as a text only RPG, diverged into using QtPy5, then to Pygame with Pygame GUI and as of this point (15/01/2022) is back to text only but being rebuilt with the use of a game engine parsing map data as per ex. 43 of Zed Shaw's book 'Learn Python the Hard Way'.
|
||||
A text adventure RPG based on the Dungeons & Dragons 5e SRD.
|
||||
|
||||
When running the code, make sure the pregen character is hashed out and you're running the startGame() function. If you're play testng, hash out this function and use a pregen to skip past character creation.
|
||||
## Running
|
||||
|
||||
While still very much a large work in progress, I am comfortable with the direction the build is taking. Focusing mostly on getting the framework completed, the later steps will be to add more story and scenes, then finally to work on a bit more graphic design.
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Pure Python stdlib — no dependencies required.
|
||||
|
||||
## Playing
|
||||
|
||||
The game uses a directed graph of scenes with directional exits (north, south, east, west). Type `help` at any prompt for a full command list. Basic commands:
|
||||
|
||||
- `go [direction]` — move between scenes
|
||||
- `look around` — see what's in the area
|
||||
- `take [item]` / `loot [container]` — pick up items
|
||||
- `examine [item]` — inspect an item
|
||||
- `inventory` / `equipment` / `stats` — review your character
|
||||
- `cast [spell]` / `rest` — spellcasting and recovery
|
||||
|
||||
## Character creation
|
||||
|
||||
Choose **"Create a new character"** for full creation with stat allocation, or **"Pre-Gen Character"** to pick one of three ready-to-play characters (Elven Wizard, Human Barbarian, Dwarven Cleric).
|
||||
|
||||
During playtesting, you can skip character creation by replacing `startGame()` in `main.py` with a pre-generated character and assigning stats directly (see `main.py:274-290` and `function_list.py:14-94`).
|
||||
|
||||
Taking many cues, pieces of advice, and explanation; this code is still 100% done and made by myself (Caveat: 29/10/2022 have brought in an Alex to assist.)
|
||||
|
||||
The code has no licence and as such is not free, is not available for modification, reduplication, sale, or any other use without explicit permission from myself.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
class Job:
|
||||
def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold):
|
||||
# NOTE ON NAMING: this represents a D&D "class" (Barbarian, Cleric, Wizard...), but `class`
|
||||
# is a reserved Python keyword and can't be used as a variable/parameter/attribute name.
|
||||
# The convention used throughout this project (per PEP 8) is:
|
||||
# - The type itself is named `CharacterClass` (capitalised type names don't collide with
|
||||
# the keyword, but "Class" alone reads ambiguously next to Python's own `class` statement).
|
||||
# - Any variable, parameter, or attribute that holds one of these is named `class_`
|
||||
# (trailing underscore), e.g. `self.class_` on Player, or `class_=Wizard`.
|
||||
class CharacterClass:
|
||||
def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold,
|
||||
spellcasting_ability=None, cantrips_known=0, spell_slots=None,
|
||||
spellbook_style=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.hitdie = hitdie
|
||||
@@ -8,6 +17,20 @@ class Job:
|
||||
self.skill = skill
|
||||
self.gold = gold
|
||||
|
||||
# --- Spellcasting (SRD) ---
|
||||
# spellcasting_ability: e.g. "Intelligence" for Wizard, "Wisdom" for Cleric. None = non-caster.
|
||||
self.spellcasting_ability = spellcasting_ability
|
||||
self.cantrips_known = cantrips_known
|
||||
# spell_slots: dict of {spell_level: slot_count} at character level 1.
|
||||
self.spell_slots = spell_slots or {}
|
||||
# spellbook_style: "spellbook" (Wizard - learns a fixed list, prepares a subset)
|
||||
# "all_known" (Cleric - knows/can prepare from their entire class list)
|
||||
self.spellbook_style = spellbook_style
|
||||
|
||||
@property
|
||||
def is_caster(self):
|
||||
return self.spellcasting_ability is not None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
profs = ""
|
||||
for key, value in self.proficiency.items():
|
||||
@@ -24,7 +47,7 @@ class Job:
|
||||
+ '\n'.join(self.skill[1:]).title() + '\n'
|
||||
|
||||
|
||||
class Barbarian(Job):
|
||||
class Barbarian(CharacterClass):
|
||||
def __init__(self):
|
||||
name = "Barbarian"
|
||||
description = "A tall human tribesman strides through a blizzard, draped in fur and hefting his axe. " \
|
||||
@@ -72,7 +95,7 @@ class Barbarian(Job):
|
||||
super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold)
|
||||
|
||||
|
||||
class Cleric(Job):
|
||||
class Cleric(CharacterClass):
|
||||
def __init__(self):
|
||||
name = "Cleric"
|
||||
description = "Arms and eyes upraised toward the sun and a prayer on his lips, an elf begins to glow with " \
|
||||
@@ -111,10 +134,12 @@ class Cleric(Job):
|
||||
"religion"
|
||||
]
|
||||
gold = [2, 10]
|
||||
super().__init__(name, description, hitdie, proficiency, savingthrow, skill, gold)
|
||||
super().__init__(name, description, hitdie, proficiency, savingthrow, skill, gold,
|
||||
spellcasting_ability="Wisdom", cantrips_known=3,
|
||||
spell_slots={1: 2}, spellbook_style="all_known")
|
||||
|
||||
|
||||
class Wizard(Job):
|
||||
class Wizard(CharacterClass):
|
||||
def __init__(self):
|
||||
name = "Wizard"
|
||||
description = "Clad in the silver robes that denote her station, an elf closes her eyes to shut out the " \
|
||||
@@ -165,9 +190,13 @@ class Wizard(Job):
|
||||
"religion"
|
||||
]
|
||||
gold = [2, 10]
|
||||
super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold)
|
||||
super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold,
|
||||
spellcasting_ability="Intelligence", cantrips_known=3,
|
||||
spell_slots={1: 2}, spellbook_style="spellbook")
|
||||
|
||||
|
||||
Barbarian = Barbarian()
|
||||
Cleric = Cleric()
|
||||
Wizard = Wizard()
|
||||
CLASSES = {
|
||||
"Barbarian": Barbarian(),
|
||||
"Cleric": Cleric(),
|
||||
"Wizard": Wizard(),
|
||||
}
|
||||
24
enum_list.py
24
enum_list.py
@@ -39,7 +39,7 @@ class Slots(Enum):
|
||||
OffHand = auto()
|
||||
TwoHanded = auto()
|
||||
Helm = auto()
|
||||
Chest = auto()
|
||||
Armor = auto()
|
||||
Wrists = auto()
|
||||
Feet = auto()
|
||||
Neck = auto()
|
||||
@@ -48,3 +48,25 @@ class Slots(Enum):
|
||||
RightRing = auto()
|
||||
Other = auto()
|
||||
Attunement = auto()
|
||||
|
||||
|
||||
class SpellSchool(Enum):
|
||||
Abjuration = auto()
|
||||
Conjuration = auto()
|
||||
Divination = auto()
|
||||
Enchantment = auto()
|
||||
Evocation = auto()
|
||||
Illusion = auto()
|
||||
Necromancy = auto()
|
||||
Transmutation = auto()
|
||||
|
||||
|
||||
class SaveType(Enum):
|
||||
"""Which stat a target rolls to resist a spell's effect."""
|
||||
Strength = auto()
|
||||
Dexterity = auto()
|
||||
Constitution = auto()
|
||||
Intelligence = auto()
|
||||
Wisdom = auto()
|
||||
Charisma = auto()
|
||||
NONE = auto() # No saving throw (e.g. Magic Missile)
|
||||
363
function_list.py
363
function_list.py
@@ -4,20 +4,102 @@ sys.path.append('.')
|
||||
|
||||
from player import Player # Only import what's necessary
|
||||
from enum_list import ItemType, Slots
|
||||
from race_list import Elf, Dwarf, Human
|
||||
from job_list import Barbarian, Cleric, Wizard
|
||||
from race_list import RACES
|
||||
from class_list import CLASSES
|
||||
|
||||
|
||||
def wait():
|
||||
input("Press enter to continue...")
|
||||
|
||||
pre_generated_characters = {
|
||||
"1": {
|
||||
"name": "Arion", # Example Name
|
||||
"gender": "Male",
|
||||
"age": 25,
|
||||
"race": RACES["Elf"],
|
||||
"class_": CLASSES["Wizard"], # Based on the High Elf sheet
|
||||
"alignment": "lawful good",
|
||||
"description": "A scholarly High Elf skilled in the arcane arts.",
|
||||
"stats": {
|
||||
"Strength": 10, "Dexterity": 16, "Constitution": 12,
|
||||
"Intelligence": 16, "Wisdom": 13, "Charisma": 8
|
||||
},
|
||||
"armor_class": "13 or 16 (mage armor)",
|
||||
"hit_points": "7 (Hit Dice 1d6 + Con modifier)",
|
||||
"speed": "30 ft.",
|
||||
"proficiencies": {
|
||||
"bonus": "+2",
|
||||
"saving_throws": {"Int": "+5", "Wis": "+3"},
|
||||
"advantage_on_saves": ["charmed"],
|
||||
"skills": {"Arcana": "+5", "History": "+5", "Investigation": "+5", "Perception": "+3", "Persuasion": "+1"},
|
||||
"armor": [], # None listed, just "None"
|
||||
"weapons": ["Daggers", "darts", "slings", "quarterstaffs", "longswords", "shortswords", "shortbows", "longbows"],
|
||||
"tools": []
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"name": "Brundle", # Example Name
|
||||
"gender": "Female",
|
||||
"age": 35,
|
||||
"race": RACES["Human"],
|
||||
"class_": CLASSES["Barbarian"], # Based on the Human sheet
|
||||
"alignment": "chaotic good",
|
||||
"description": "A robust Human warrior, charging into battle.",
|
||||
"stats": {
|
||||
"Strength": 16, "Dexterity": 9, "Constitution": 15,
|
||||
"Intelligence": 13, "Wisdom": 11, "Charisma": 14
|
||||
},
|
||||
"armor_class": "18",
|
||||
"hit_points": "12 (Hit Dice 1d10 + Con modifier))",
|
||||
"speed": "30 ft.",
|
||||
"proficiencies": {
|
||||
"bonus": "+2",
|
||||
"saving_throws": {"Str": "+5", "Con": "+4"},
|
||||
"skills": {"Athletics": "+5", "History": "+3", "Intimidation": "+4", "Perception": "+2"},
|
||||
"armor": ["All", "shields"],
|
||||
"weapons": ["Simple", "martial"],
|
||||
"tools": ["Gaming dice", "vehicles (land)"],
|
||||
"senses": {"Passive Perception": "12"},
|
||||
"languages": ["Common", "Orc"]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"name": "Drok", # Example Name
|
||||
"gender": "Other",
|
||||
"age": 75, # Dwarves live longer
|
||||
"race": RACES["Dwarf"],
|
||||
"class_": CLASSES["Cleric"], # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus)
|
||||
"alignment": "lawful good",
|
||||
"description": "A stout Hill Dwarf cleric, a pillar of his community.",
|
||||
"stats": {
|
||||
"Strength": 14, "Dexterity": 8, "Constitution": 15,
|
||||
"Intelligence": 10, "Wisdom": 16, "Charisma": 12
|
||||
},
|
||||
"armor_class": "18 (chain mail, shield)",
|
||||
"hit_points": "10 (Hit Dice 1d8 + Con modifier))",
|
||||
"speed": "25 ft.",
|
||||
"proficiencies": {
|
||||
"bonus": "+2",
|
||||
"saving_throws": {"Wis": "+5", "Cha": "+3"},
|
||||
"advantage_on_saves": ["poisoned"],
|
||||
"skills": {"Insight": "+5", "Medicine": "+5", "Persuasion": "+3", "Religion": "+2"},
|
||||
"armor": ["all armor", "shields"],
|
||||
"weapons": ["battleaxe", "simple weapons", "warhammer"],
|
||||
"tools": ["brewer's supplies", "jeweler's tools"],
|
||||
"damage_resistances": ["poison"],
|
||||
"senses": {"darkvision": "60 ft.", "passive_perception": "13"},
|
||||
"languages": ["Common", "Dwarvish", "Giant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def startGame():
|
||||
print("Welcome stranger, to the world of Kanjin!.")
|
||||
# Loop for initial choice: Create or Pre-generated
|
||||
while True:
|
||||
start = input("Would you like to create your own character or use pre-generated stats?"
|
||||
"\n 1) Create a new character\n 2) Pre-generated\n >> ")
|
||||
|
||||
"\n 1) Create a new character\n 2) Quick Setup\n 3) Pre-Gen Character\n >> ")
|
||||
if start in ("1", "Create"):
|
||||
# --- Character Basic Info (Name, Gender, Age) ---
|
||||
name, gender, age = None, None, None # Initialize
|
||||
@@ -60,10 +142,10 @@ def startGame():
|
||||
print("Sorry, I didn't catch that. Please try again.\n")
|
||||
continue
|
||||
|
||||
# --- Race and Job Selection ---
|
||||
# --- Race and Class Selection ---
|
||||
race = None
|
||||
job = None
|
||||
# Loop until Race and Job are confirmed
|
||||
class_ = None
|
||||
# Loop until Race and Class are confirmed
|
||||
while True:
|
||||
# Race Selection
|
||||
selected_race_obj = None
|
||||
@@ -72,11 +154,11 @@ def startGame():
|
||||
race_choice = input("Please select a race to learn more about it:\n"
|
||||
"1) Elf\n2) Dwarf\n3) Human\n >> ").title()
|
||||
if race_choice in ['Elf', '1']:
|
||||
selected_race_obj = Elf
|
||||
selected_race_obj = RACES["Elf"]
|
||||
elif race_choice in ['Dwarf', '2']:
|
||||
selected_race_obj = Dwarf
|
||||
selected_race_obj = RACES["Dwarf"]
|
||||
elif race_choice in ['Human', '3']:
|
||||
selected_race_obj = Human
|
||||
selected_race_obj = RACES["Human"]
|
||||
else:
|
||||
print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n")
|
||||
continue
|
||||
@@ -95,97 +177,186 @@ def startGame():
|
||||
print("Sorry, I didn't catch that. Please try again.\n")
|
||||
continue
|
||||
|
||||
# Job Selection
|
||||
selected_job_obj = None
|
||||
# Loop for selecting and viewing job
|
||||
# Class Selection
|
||||
selected_class_obj = None
|
||||
# Loop for selecting and viewing class
|
||||
while True:
|
||||
job_choice = input("Please select a job to learn more about it:\n"
|
||||
class_choice = input("Please select a class to learn more about it:\n"
|
||||
"1) Barbarian\n2) Cleric\n3) Wizard\n >> ").title()
|
||||
if job_choice in ['Barbarian', '1']:
|
||||
selected_job_obj = Barbarian
|
||||
elif job_choice in ['Cleric', '2']:
|
||||
selected_job_obj = Cleric
|
||||
elif job_choice in ['Wizard', '3']:
|
||||
selected_job_obj = Wizard
|
||||
if class_choice in ['Barbarian', '1']:
|
||||
selected_class_obj = CLASSES["Barbarian"]
|
||||
elif class_choice in ['Cleric', '2']:
|
||||
selected_class_obj = CLASSES["Cleric"]
|
||||
elif class_choice in ['Wizard', '3']:
|
||||
selected_class_obj = CLASSES["Wizard"]
|
||||
else:
|
||||
print("Sorry I didn't recognise that job. Please select 'Barbarian', 'Cleric', or 'Wizard'.\n")
|
||||
print("Sorry I didn't recognise that class. Please select 'Barbarian', 'Cleric', or 'Wizard'.\n")
|
||||
continue
|
||||
|
||||
if selected_job_obj:
|
||||
print(f"\n--- {selected_job_obj.name} ---")
|
||||
print(selected_job_obj.__repr__())
|
||||
if selected_class_obj:
|
||||
print(f"\n--- {selected_class_obj.name} ---")
|
||||
print(selected_class_obj.__repr__())
|
||||
|
||||
print('Would you like to proceed with this job or view another?')
|
||||
proceed = input('1) Proceed\n2) View another job\n >> ')
|
||||
print('Would you like to proceed with this class or view another?')
|
||||
proceed = input('1) Proceed\n2) View another class\n >> ')
|
||||
if proceed.lower() in ('1', 'proceed'):
|
||||
job = selected_job_obj # Assign job object
|
||||
break # Exit job selection loop
|
||||
elif proceed.lower() in ('2', 'view', 'view another', 'view another job'):
|
||||
continue # Restart job selection
|
||||
class_ = selected_class_obj # Assign class object
|
||||
break # Exit class selection loop
|
||||
elif proceed.lower() in ('2', 'view', 'view another', 'view another class'):
|
||||
continue # Restart class selection
|
||||
else:
|
||||
print("Sorry, I didn't catch that. Please try again.\n")
|
||||
continue
|
||||
|
||||
# Final confirmation for both Race and Job
|
||||
# Final confirmation for both Race and Class
|
||||
while True:
|
||||
# This safeguard should ideally not be hit if inner loops work correctly
|
||||
if race is None or job is None:
|
||||
print("Error: Race or Job not selected. Restarting Race/Job selection.")
|
||||
break # Break out of this inner loop to restart the outer race/job loop
|
||||
correct = input(f"\n{name}, you are a {race.name_adjective} {job.name}.\nIs this correct? Y/N\n >> ")
|
||||
if race is None or class_ is None:
|
||||
print("Error: Race or Class not selected. Restarting Race/Class selection.")
|
||||
break # Break out of this inner loop to restart the outer race/class loop
|
||||
correct = input(f"\n{name}, you are a {race.name_adjective} {class_.name}.\nIs this correct? Y/N\n >> ")
|
||||
if correct.lower() in ['y', 'yes']:
|
||||
return name, gender, age, race, job # All confirmed, return values
|
||||
return name, gender, age, race, class_, None# All confirmed, return values
|
||||
elif correct.lower() in ['n', 'no']:
|
||||
# If not correct, break this loop to re-enter race/job selection
|
||||
# If not correct, break this loop to re-enter race/class selection
|
||||
break
|
||||
else:
|
||||
print("Sorry, I didn't catch that. Please try again.\n")
|
||||
continue
|
||||
|
||||
elif start in ("2", "pre-generated"):
|
||||
elif start in ("2", "Quick Setup"):
|
||||
print("Please be prepared to enter a name, gender, age, race, and class, from those available in the game."
|
||||
"\nIf you are unsure what the options are, please go back and create a new character.")
|
||||
create = input("Do you wish to continue? Y/N\n >> ").lower()
|
||||
if create in ("y", "yes"):
|
||||
name = input("Name: ")
|
||||
gender = input("Gender: ")
|
||||
gender = input("Gender (Male, Female, Other): ")
|
||||
age = int(input("Age: "))
|
||||
race_str = input("Race (Elf, Dwarf, Human): ")
|
||||
job_str = input("Job (Barbarian, Cleric, Wizard): ")
|
||||
class_str = input("Class (Barbarian, Cleric, Wizard): ")
|
||||
|
||||
# Map string inputs to actual Race/Job objects for consistency
|
||||
race_map = {'elf': Elf, 'dwarf': Dwarf, 'human': Human}
|
||||
job_map = {'barbarian': Barbarian, 'cleric': Cleric, 'wizard': Wizard}
|
||||
# Map string inputs to actual Race/Class objects for consistency
|
||||
race_map = {'elf': RACES["Elf"], 'dwarf': RACES["Dwarf"], 'human': RACES["Human"]}
|
||||
class_map = {'barbarian': CLASSES["Barbarian"], 'cleric': CLASSES["Cleric"], 'wizard': CLASSES["Wizard"]}
|
||||
|
||||
actual_race = race_map.get(race_str.lower())
|
||||
actual_job = job_map.get(job_str.lower())
|
||||
actual_class = class_map.get(class_str.lower())
|
||||
|
||||
if actual_race and actual_job:
|
||||
return name, gender, age, actual_race, actual_job
|
||||
if actual_race and actual_class:
|
||||
return name, gender, age, actual_race, actual_class, None
|
||||
else:
|
||||
print("Invalid race or job entered for pre-generated character. Please try again.")
|
||||
# Force restart to character creation choice by setting 'start' to '1'
|
||||
# and then continue the outermost loop.
|
||||
print("Invalid race or class entered for pre-generated character. Please try again.")
|
||||
start = "1"
|
||||
continue
|
||||
return None
|
||||
elif create in ("n", "no"):
|
||||
# If 'n', re-prompt the initial choice.
|
||||
start = input("Would you like to create your own character or use pre-generated stats?"
|
||||
"\n 1) Create a new character\n 2) Pre-generated\n >> ")
|
||||
"\n 1) Create a new character\n 2) Quick Setup\n 3) Pre-Gen Character\n >> ")
|
||||
continue
|
||||
return None
|
||||
else:
|
||||
print("Invalid input. Please enter Y or N.")
|
||||
# If invalid, re-prompt the initial choice.
|
||||
start = input("Would you like to create your own character or use pre-generated stats?"
|
||||
"\n 1) Create a new character\n 2) Pre-generated\n >> ")
|
||||
"\n 1) Create a new character\n 2) Quick Setup\n 3) Pre-Gen Character\n >> ")
|
||||
continue
|
||||
else:
|
||||
print("Invalid input. Please select '1' or '2'.")
|
||||
# Re-prompt the initial choice.
|
||||
start = input("Would you like to create your own character or use pre-generated stats?"
|
||||
"\n 1) Create a new character\n 2) Pre-generated\n >> ")
|
||||
return None
|
||||
elif start in ("3", "Pre-generated Character"):
|
||||
while True:
|
||||
print("Who would you like to to play?")
|
||||
for key, char_data in pre_generated_characters.items():
|
||||
print(
|
||||
f"{key}) {char_data['name']} ({char_data['race'].name_adjective} {char_data['class_'].name}, {char_data['alignment']})")
|
||||
print("Type 'back' to return to character creation options.")
|
||||
|
||||
char_choice = input(">> ").strip().lower()
|
||||
|
||||
if char_choice == "back":
|
||||
break
|
||||
|
||||
if char_choice in pre_generated_characters:
|
||||
selected_char_data = pre_generated_characters[char_choice]
|
||||
|
||||
# --- Display Character Details for Confirmation ---
|
||||
print(f"\n--- {selected_char_data['name']}'s Details ---")
|
||||
print(f"Name: {selected_char_data['name']}")
|
||||
print(f"Gender: {selected_char_data['gender']}")
|
||||
print(f"Age: {selected_char_data['age']}")
|
||||
print(f"Race: {selected_char_data['race'].name_adjective}")
|
||||
print(f"Class: {selected_char_data['class_'].name}")
|
||||
print(f"Alignment: {selected_char_data['alignment']}")
|
||||
print(f"Description: {selected_char_data['description']}")
|
||||
print(f"Age: {selected_char_data['age']}")
|
||||
print(f"\nArmor Class: {selected_char_data['armor_class']}")
|
||||
print(f"Hit Points: {selected_char_data['hit_points']}")
|
||||
print(f"Speed: {selected_char_data['speed']}")
|
||||
|
||||
print("\n--- Stats ---")
|
||||
for stat_name, value in selected_char_data['stats'].items():
|
||||
# Calculate modifier for display (assuming standard D&D rules)
|
||||
modifier = (value - 10) // 2
|
||||
modifier_sign = "+" if modifier >= 0 else ""
|
||||
print(f"{stat_name}: {value} ({modifier_sign}{modifier})")
|
||||
|
||||
print("\n--- Proficiencies & Abilities ---")
|
||||
print(f"Proficiency Bonus: {selected_char_data['proficiencies']['bonus']}")
|
||||
print(
|
||||
f"Saving Throws: {', '.join([f'{stat} {val}' for stat, val in selected_char_data['proficiencies']['saving_throws'].items()])}")
|
||||
if selected_char_data['proficiencies'].get('advantage_on_saves'):
|
||||
print(
|
||||
f" Advantage on saves against: {', '.join(selected_char_data['proficiencies']['advantage_on_saves'])}")
|
||||
print(
|
||||
f"Skills: {', '.join([f'{skill} {val}' for skill, val in selected_char_data['proficiencies']['skills'].items()])}")
|
||||
if selected_char_data['proficiencies'].get('armor'):
|
||||
print(f"Armor Proficiencies: {', '.join(selected_char_data['proficiencies']['armor'])}")
|
||||
if selected_char_data['proficiencies'].get('weapons'):
|
||||
print(f"Weapon Proficiencies: {', '.join(selected_char_data['proficiencies']['weapons'])}")
|
||||
if selected_char_data['proficiencies'].get('tools'):
|
||||
print(f"Tool Proficiencies: {', '.join(selected_char_data['proficiencies']['tools'])}")
|
||||
if selected_char_data['proficiencies'].get('damage_resistances'):
|
||||
print(
|
||||
f"Damage Resistances: {', '.join(selected_char_data['proficiencies']['damage_resistances'])}")
|
||||
if selected_char_data['proficiencies'].get('senses'):
|
||||
senses_str = []
|
||||
if 'darkvision' in selected_char_data['proficiencies']['senses']:
|
||||
senses_str.append(
|
||||
f"Darkvision {selected_char_data['proficiencies']['senses']['darkvision']}")
|
||||
if 'passive_perception' in selected_char_data['proficiencies']['senses']:
|
||||
senses_str.append(
|
||||
f"Passive Perception {selected_char_data['proficiencies']['senses']['passive_perception']}")
|
||||
print(f"Senses: {', '.join(senses_str)}")
|
||||
if selected_char_data['proficiencies'].get('languages'):
|
||||
print(f"Languages: {', '.join(selected_char_data['proficiencies']['languages'])}")
|
||||
|
||||
while True:
|
||||
print("\nDo you want to select this character?")
|
||||
print("1) Yes, select this character")
|
||||
print("2) No, go back to character selection")
|
||||
confirm_choice = input(">> ").strip().lower()
|
||||
|
||||
if confirm_choice in ("1", "yes"):
|
||||
name = selected_char_data['name']
|
||||
gender = selected_char_data['gender']
|
||||
age = selected_char_data['age']
|
||||
race = selected_char_data['race']
|
||||
class_ = selected_char_data['class_']
|
||||
pre_allocated_stats = selected_char_data['stats']
|
||||
|
||||
print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {class_.name}.")
|
||||
return name, gender, age, race, class_, pre_allocated_stats # Exit all loops and function
|
||||
|
||||
elif confirm_choice in ("2", "no"):
|
||||
print("\nReturning to pre-generated character selection.")
|
||||
break # Break out of confirmation loop, go back to char_choice loop
|
||||
else:
|
||||
print("Invalid choice. Please enter '1', '2', 'yes', or 'no'.")
|
||||
|
||||
else:
|
||||
print("Invalid selection. Please choose a number from the list or 'back'.")
|
||||
continue
|
||||
|
||||
|
||||
def query_equip(player: Player):
|
||||
"""Allows the player to view or change equipped items."""
|
||||
while True:
|
||||
@@ -255,7 +426,7 @@ def change_equip(player: Player):
|
||||
itemlist = [item_obj for item_obj, item_data in player.inventory.items.items()
|
||||
if item_data["object"] == ItemType.Armor and
|
||||
item_obj not in [player.inventory.equipped_items[Slots.Helm],
|
||||
player.inventory.equipped_items[Slots.Chest],
|
||||
player.inventory.equipped_items[Slots.Armor],
|
||||
player.inventory.equipped_items[Slots.Wrists],
|
||||
player.inventory.equipped_items[Slots.Feet]]]
|
||||
itemlist.append("Return")
|
||||
@@ -280,8 +451,8 @@ def change_equip(player: Player):
|
||||
# Logic for equipping armor based on its intended slot
|
||||
if chosen_item.slot == Slots.Helm:
|
||||
player.inventory.equip_item(chosen_item, Slots.Helm)
|
||||
elif chosen_item.slot == Slots.Chest:
|
||||
player.inventory.equip_item(chosen_item, Slots.Chest)
|
||||
elif chosen_item.slot == Slots.Armor:
|
||||
player.inventory.equip_item(chosen_item, Slots.Armor)
|
||||
elif chosen_item.slot == Slots.Wrists:
|
||||
player.inventory.equip_item(chosen_item, Slots.Wrists)
|
||||
elif chosen_item.slot == Slots.Feet:
|
||||
@@ -347,14 +518,29 @@ def change_equip(player: Player):
|
||||
|
||||
|
||||
def get_instructions():
|
||||
descrip1 = ("There are certain commands that will be available almost anytime you are able to type,\n"
|
||||
descrip1 = ("There are certain commands that will be available almost anytime you are able to type\n"
|
||||
"such as viewing your inventory, checking your equipped items, and also changing them.\n"
|
||||
"You can also view your stats including your current and max hp.\n")
|
||||
descrip2 = ("Some examples are: 'Check inventory', 'Check equipment', and 'View stats'.\n"
|
||||
"To travel to a new area, just type 'Go north' or 'enter cave' etc.\n"
|
||||
"To replay the description of the current area, type 'location'.")
|
||||
descrip2 = ("Help - Prints this help message.\n"
|
||||
"Check inventory | bag | backpack - Lists items you are carrying.\n"
|
||||
" Can also just enter 'inventory | bag | backpack\n"
|
||||
"Check equipment | equipped | items - Lists items you have equipped\n"
|
||||
" Can also just enter 'equipment | equipped | items\n"
|
||||
"Check stats or Check hitpoints | hp | health - Shows your stat block or current health details\n"
|
||||
" Can also just enter 'stats | health | hitpoints | hp\n"
|
||||
"Go *direction* - Each scene will give you available directions i.e North\n"
|
||||
"Enter cave | house | room - maybe... TBC\n"
|
||||
"Scene or Location - Replays the current area's details\n"
|
||||
"Look around - lists the available items in your scene without displaying the scene description.\n"
|
||||
"Examine | Open | Loot *object* - Provides details of an item, opens a container, or takes an object.\n"
|
||||
"Spells | Spellbook - Lists your cantrips, known/preparable spells, prepared spells, and slots.\n"
|
||||
"Cast *spell name* - Casts a cantrip or prepared spell.\n"
|
||||
"Prepare | Unprepare *spell name* - Moves a spell in/out of your prepared list.\n"
|
||||
"Rest - Long rest: restores HP and spell slots.\n"
|
||||
"Check weight | carry | encumbrance - Shows carrying weight vs capacity.\n"
|
||||
"Check currency | coins | gold - Shows coinage held and total gp value.")
|
||||
print(descrip1)
|
||||
time.sleep(1) # Reduced sleep for faster testing
|
||||
time.sleep(3) # Reduced sleep for faster testing
|
||||
print(descrip2)
|
||||
|
||||
|
||||
@@ -376,7 +562,7 @@ def parse(input_text):
|
||||
if words[0] == "check" and words[1] in ("inventory", "bag", "backpack"):
|
||||
command = "inventory"
|
||||
return command, object1
|
||||
elif words[0] == "check" and words[1] in ("equipment", "equip", "items"):
|
||||
elif words[0] == "check" and words[1] in ("equipment", "equipped", "items"):
|
||||
command = "equipment"
|
||||
return command, object1
|
||||
elif words[0] == "check" and words[1] == "stats":
|
||||
@@ -385,7 +571,7 @@ def parse(input_text):
|
||||
elif words[0] == "check" and words[1] in ("hp", "hitpoints", "health"):
|
||||
command = "hp"
|
||||
return command, object1
|
||||
elif words[0] == "go":
|
||||
elif words[0] in ("go", "enter", "exit"):
|
||||
command = "go"
|
||||
object1 = " ".join(words[1:]) # The rest of the words are the direction
|
||||
return command, object1
|
||||
@@ -393,15 +579,40 @@ def parse(input_text):
|
||||
command = "take"
|
||||
object1 = " ".join(words[2:])
|
||||
return command, object1
|
||||
elif words[0] == "look" and words[1] == "around" and len(words) == 2:
|
||||
command = "look_around"
|
||||
return command, object1
|
||||
elif words[0] == "exit" and words[1] == "tutorial" and len(words) == 2:
|
||||
command = "go"
|
||||
object1 = " ".join(words[1:])
|
||||
return command, object1
|
||||
elif words[0] == "cast":
|
||||
command = "cast"
|
||||
object1 = " ".join(words[1:])
|
||||
return command, object1
|
||||
elif words[0] == "prepare":
|
||||
command = "prepare"
|
||||
object1 = " ".join(words[1:])
|
||||
return command, object1
|
||||
elif words[0] == "unprepare":
|
||||
command = "unprepare"
|
||||
object1 = " ".join(words[1:])
|
||||
return command, object1
|
||||
elif words[0] == "check" and words[1] in ("weight", "carry", "encumbrance"):
|
||||
command = "weight"
|
||||
return command, object1
|
||||
elif words[0] == "check" and words[1] in ("currency", "coins", "gold", "money"):
|
||||
command = "currency"
|
||||
return command, object1
|
||||
|
||||
# Single-word commands
|
||||
if words[0] == "help":
|
||||
command = "help"
|
||||
elif words[0] == "scene" or words[0] == "location": # Added 'location' as an alias
|
||||
elif words[0] in ("scene", "location"):
|
||||
command = "scene"
|
||||
elif words[0] in ("inventory", "bag", "backpack"):
|
||||
command = "inventory"
|
||||
elif words[0] in ("equip", "equipment"):
|
||||
elif words[0] in ("equipped", "equipment"):
|
||||
command = "equipment"
|
||||
elif words[0] == "stats":
|
||||
command = "stats"
|
||||
@@ -428,6 +639,13 @@ def parse(input_text):
|
||||
else:
|
||||
command = "loot" # User needs to specify what to loot
|
||||
object1 = None
|
||||
elif words[0] == "open":
|
||||
if len(words) > 1:
|
||||
command = "open"
|
||||
object1 = " ".join(words[1:])
|
||||
else:
|
||||
command = "open" # User needs to specify what to open
|
||||
object1 = None
|
||||
elif words[0] == "drop":
|
||||
if len(words) > 1:
|
||||
command = "drop"
|
||||
@@ -435,6 +653,15 @@ def parse(input_text):
|
||||
else:
|
||||
command = "drop" # User needs to specify what to drop
|
||||
object1 = None
|
||||
|
||||
elif words[0] in ("spells", "spellbook"):
|
||||
command = "spells"
|
||||
elif words[0] == "rest":
|
||||
command = "rest"
|
||||
elif words[0] in ("weight", "carry", "encumbrance"):
|
||||
command = "weight"
|
||||
elif words[0] in ("currency", "coins", "gold", "money"):
|
||||
command = "currency"
|
||||
elif words[0] == "quit":
|
||||
command = "quit"
|
||||
else:
|
||||
|
||||
361
main.py
361
main.py
@@ -1,21 +1,50 @@
|
||||
import sys
|
||||
from random import randint
|
||||
# Add the current directory to sys.path to allow imports from sibling files
|
||||
sys.path.append('.')
|
||||
# Import classes and functions from your existing files
|
||||
from player import Player, Inventory
|
||||
from function_list import startGame, parse, error_message, get_instructions
|
||||
from job_list import Barbarian, Cleric, Wizard
|
||||
from race_list import Elf, Dwarf, Human
|
||||
from player import Player
|
||||
from function_list import startGame, parse, error_message, get_instructions, wait
|
||||
from scene import *
|
||||
|
||||
|
||||
def disambiguate(query, candidates, key_fn=str):
|
||||
"""
|
||||
Given a query string and a list of candidates, find candidates whose
|
||||
key (extracted by key_fn) contains query (case-insensitive substring match).
|
||||
Returns the single matched candidate, or prompts the user on ambiguity.
|
||||
Returns None if no match or user backs out.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
matches = [c for c in candidates if query_lower in key_fn(c).lower()]
|
||||
|
||||
if len(matches) == 0:
|
||||
return None
|
||||
elif len(matches) == 1:
|
||||
return matches[0]
|
||||
|
||||
print("Which do you mean?")
|
||||
for i, candidate in enumerate(matches, 1):
|
||||
print(f" {i}) {key_fn(candidate).title()}")
|
||||
print(" b) Back")
|
||||
|
||||
while True:
|
||||
choice = input(">> ").strip().lower()
|
||||
if choice in ("b", "back"):
|
||||
return None
|
||||
if choice.isdigit():
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= len(matches):
|
||||
return matches[idx - 1]
|
||||
print("Invalid choice.")
|
||||
|
||||
|
||||
class Engine:
|
||||
"""
|
||||
The main game engine class to manage game state, current scene, and player.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.player = None # Will be initialized after character creation
|
||||
self.current_scene = starting_clearing # Start the game in the starting clearing
|
||||
self.current_scene = tutorial # Start the game in the tutorial
|
||||
self.seed = self.current_scene.name # String representation of current scene name
|
||||
|
||||
def set_player(self, player_obj):
|
||||
@@ -38,9 +67,26 @@ class Engine:
|
||||
print(f" - {item_obj.name} (x{count})")
|
||||
# Display lootable containers
|
||||
if self.current_scene.lootable_items:
|
||||
print("\nYou also notice some containers:")
|
||||
for container_name, items_in_container in self.current_scene.lootable_items.items():
|
||||
if any(count > 0 for count in items_in_container.values()): # Check if container has any items left
|
||||
print("\nYou also notice:")
|
||||
for container_name, container in self.current_scene.lootable_items.items():
|
||||
if container.has_items():
|
||||
print(f" - {container_name.title()}")
|
||||
|
||||
def look_around(self):
|
||||
if self.current_scene.exits:
|
||||
exit_directions = ", ".join(self.current_scene.exits.keys()).title()
|
||||
print(f"Exits: {exit_directions}")
|
||||
# Display available items
|
||||
if self.current_scene.available_items:
|
||||
print("\nAround you, you see:")
|
||||
for item_obj, count in self.current_scene.available_items.items():
|
||||
if count > 0:
|
||||
print(f" - {item_obj.name} (x{count})")
|
||||
# Display lootable containers
|
||||
if self.current_scene.lootable_items:
|
||||
print("\nYou also notice:")
|
||||
for container_name, container in self.current_scene.lootable_items.items():
|
||||
if container.has_items():
|
||||
print(f" - {container_name.title()}")
|
||||
|
||||
|
||||
@@ -93,7 +139,7 @@ class Engine:
|
||||
elif isinstance(item_obj, Armor):
|
||||
print(f"AC: {item_obj.ac}")
|
||||
print(f"Grade: {item_obj.grade}")
|
||||
print(f"Value: {item_obj.value if item_obj.value is not None else 'N/A'}")
|
||||
print(f"Value: {item_obj.value if item_obj.value is not None else 'No value'}")
|
||||
print(f"Magical: {item_obj.magical}")
|
||||
print(f"Attunement: {'Required' if item_obj.attunement else 'Not Required'}")
|
||||
found_in_scene = True
|
||||
@@ -101,39 +147,80 @@ class Engine:
|
||||
|
||||
if not found_in_scene:
|
||||
# Check lootable containers in the current scene
|
||||
found_in_loot = False
|
||||
for container_name, items_in_container in self.current_scene.lootable_items.items():
|
||||
if object_name.lower() in container_name.lower(): # If user tries to examine the container itself
|
||||
print(f"\nYou examine the {container_name}. Inside, you see:")
|
||||
if items_in_container:
|
||||
for item_obj, count in items_in_container.items():
|
||||
if count > 0:
|
||||
print(f" - {item_obj.name} (x{count})")
|
||||
else:
|
||||
print("It appears to be empty.")
|
||||
found_in_loot = True
|
||||
break # Exit after examining the container
|
||||
pairs = list(self.current_scene.lootable_items.items())
|
||||
match = disambiguate(object_name, pairs, key_fn=lambda kv: kv[0])
|
||||
|
||||
for item_obj, count in items_in_container.items():
|
||||
if object_name.lower() in item_obj.name.lower() and count > 0:
|
||||
print(f"\n--- {item_obj.name} ---")
|
||||
print(item_obj.description)
|
||||
if isinstance(item_obj, Weapon):
|
||||
print(f"Damage: {item_obj.damage1H or item_obj.damage2H or item_obj.versatiledmg}")
|
||||
print(f"Damage Type: {item_obj.dmgType.name.title()}")
|
||||
elif isinstance(item_obj, Armor):
|
||||
print(f"AC: {item_obj.ac}")
|
||||
print(f"Grade: {item_obj.grade}")
|
||||
print(f"Value: {item_obj.value if item_obj.value is not None else 'N/A'}")
|
||||
print(f"Magical: {item_obj.magical}")
|
||||
print(f"Attunement: {'Required' if item_obj.attunement else 'Not Required'}")
|
||||
found_in_loot = True
|
||||
break
|
||||
if found_in_loot:
|
||||
break
|
||||
if match:
|
||||
container_name, container = match
|
||||
self._examine_container(container_name, container)
|
||||
else:
|
||||
# Check items inside containers
|
||||
candidates = []
|
||||
for container_name, container in self.current_scene.lootable_items.items():
|
||||
for item_obj, count in container.contents.items():
|
||||
if count > 0 and object_name.lower() in item_obj.name.lower():
|
||||
candidates.append((container_name, container, item_obj))
|
||||
|
||||
if not found_in_loot:
|
||||
print(f"You don't see or have '{object_name}' to examine.")
|
||||
seen = set()
|
||||
unique_candidates = []
|
||||
for cn, c, item in candidates:
|
||||
key = item.name.lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_candidates.append((cn, c, item))
|
||||
|
||||
match = disambiguate(object_name, unique_candidates, key_fn=lambda t: t[2].name)
|
||||
if match:
|
||||
container_name, container, item_obj = match
|
||||
print(f"\n--- {item_obj.name} ---")
|
||||
print(item_obj.description)
|
||||
if isinstance(item_obj, Weapon):
|
||||
print(f"Damage: {item_obj.damage1H or item_obj.damage2H or item_obj.versatiledmg}")
|
||||
print(f"Damage Type: {item_obj.dmgType.name.title()}")
|
||||
elif isinstance(item_obj, Armor):
|
||||
print(f"AC: {item_obj.ac}")
|
||||
print(f"Grade: {item_obj.grade}")
|
||||
print(f"Value: {item_obj.value if item_obj.value is not None else 'N/A'}")
|
||||
print(f"Magical: {item_obj.magical}")
|
||||
print(f"Attunement: {'Required' if item_obj.attunement else 'Not Required'}")
|
||||
else:
|
||||
print(f"You don't see or have '{object_name}' to examine.")
|
||||
|
||||
def _examine_container(self, container_name, container):
|
||||
"""Print details of a container and its contents."""
|
||||
print(f"\n--- {container.name.title()} ---")
|
||||
print(container.description)
|
||||
|
||||
int_mod = self.player.mods["Intelligence"]
|
||||
noticed_something = False
|
||||
|
||||
if container.lock_dc:
|
||||
roll = randint(1, 20) + int_mod
|
||||
print(f"\nInvestigation check: 1d20{int_mod:+} = {roll} vs DC {container.lock_dc}")
|
||||
if roll >= container.lock_dc:
|
||||
print("It appears to be locked.")
|
||||
noticed_something = True
|
||||
elif not container.trap_dc:
|
||||
print("You don't notice anything unusual about it.")
|
||||
|
||||
if container.trap_dc:
|
||||
roll = randint(1, 20) + int_mod
|
||||
print(f"\nInvestigation check: 1d20{int_mod:+} = {roll} vs DC {container.trap_dc}")
|
||||
if roll >= container.trap_dc:
|
||||
print("You notice a trap mechanism on it.")
|
||||
container.trap_detected = True
|
||||
noticed_something = True
|
||||
elif not container.lock_dc and not noticed_something:
|
||||
print("You don't notice anything unusual about it.")
|
||||
|
||||
if not container.is_locked:
|
||||
if container.contents:
|
||||
print("\nInside, you see:")
|
||||
for item_obj, count in container.contents.items():
|
||||
if count > 0:
|
||||
print(f" - {item_obj.name} (x{count})")
|
||||
else:
|
||||
print("It appears to be empty.")
|
||||
|
||||
def take_item(self, item_name):
|
||||
"""
|
||||
@@ -169,72 +256,96 @@ class Engine:
|
||||
else:
|
||||
print("You don't see any of those lying around to take.")
|
||||
|
||||
|
||||
def loot_container(self, container_name):
|
||||
"""
|
||||
Attempts to loot items from a container in the current scene.
|
||||
:param container_name: The name of the container to loot.
|
||||
"""
|
||||
found_container = False
|
||||
container_items = {}
|
||||
for c_name, items_in_c in self.current_scene.lootable_items.items():
|
||||
if container_name.lower() in c_name.lower():
|
||||
container_items = items_in_c
|
||||
found_container = True
|
||||
break
|
||||
"""Allows the player to loot items from a specified container."""
|
||||
pairs = list(self.current_scene.lootable_items.items())
|
||||
match = disambiguate(container_name, pairs, key_fn=lambda kv: kv[0])
|
||||
if not match:
|
||||
print(f"You don't see a '{container_name}' here to loot.")
|
||||
return
|
||||
|
||||
if found_container:
|
||||
if not container_items or all(count == 0 for count in container_items.values()):
|
||||
print(f"The {container_name} is empty.")
|
||||
current_name, container = match
|
||||
|
||||
if container.is_locked:
|
||||
print(f"The {current_name} is locked.")
|
||||
return
|
||||
|
||||
if container.is_trapped and not container.trap_disarmed:
|
||||
if container.trap_detected:
|
||||
print(f"The {current_name} is trapped. You'll need to disable the trap first.")
|
||||
return
|
||||
else:
|
||||
print(f"You open the {current_name}. A trap is triggered!")
|
||||
# TODO: trap damage/effects
|
||||
return
|
||||
|
||||
print(f"You look inside the {container_name}. You see:")
|
||||
loot_options = []
|
||||
for i, (item_obj, count) in enumerate(container_items.items()):
|
||||
if count > 0:
|
||||
print(f"{i+1}) {item_obj.name} (x{count})")
|
||||
loot_options.append((item_obj, count))
|
||||
print(f"{len(loot_options) + 1}) Take all")
|
||||
print(f"{len(loot_options) + 2}) Leave")
|
||||
if not container.contents:
|
||||
print(f"The {current_name} is empty.")
|
||||
return
|
||||
|
||||
while True:
|
||||
print(f"You look inside the {current_name}. You see:")
|
||||
loot_list = list(container.contents.items())
|
||||
|
||||
while True:
|
||||
for i, (item_obj, count) in enumerate(loot_list, 1):
|
||||
print(f"{i}) {item_obj.name} (x{count})")
|
||||
|
||||
take_all_option = len(loot_list) + 1
|
||||
leave_option = len(loot_list) + 2
|
||||
|
||||
print(f"{take_all_option}) Take all")
|
||||
print(f"{leave_option}) Leave")
|
||||
|
||||
choice = input("What would you like to take? (number or 'take all'/'leave')\n>> ").lower().strip()
|
||||
|
||||
if choice == "take all" or (choice.isdigit() and int(choice) == take_all_option):
|
||||
for item_obj, count in list(container.contents.items()):
|
||||
self.player.inventory.add_item(item_obj, count)
|
||||
print(f"- Took {count} {item_obj.name}")
|
||||
container.remove_item(item_obj, count)
|
||||
print(f"The {current_name} is now empty.")
|
||||
if not container.has_items():
|
||||
del self.current_scene.lootable_items[current_name]
|
||||
return
|
||||
elif choice == "leave" or (choice.isdigit() and int(choice) == leave_option):
|
||||
print(f"You leave the {current_name} untouched.")
|
||||
return
|
||||
else:
|
||||
try:
|
||||
choice = input("What would you like to take? (number or 'take all'/'leave')\n>> ").lower()
|
||||
if choice == "leave":
|
||||
print("You close the container.")
|
||||
break
|
||||
elif choice == "take all":
|
||||
for item_obj, count in loot_options:
|
||||
self.player.inventory.add_item(item_obj, count)
|
||||
self.current_scene.remove_lootable_item(container_name, item_obj, count)
|
||||
print(f"You took everything from the {container_name}.")
|
||||
break
|
||||
elif choice.isdigit():
|
||||
index = int(choice) - 1
|
||||
if 0 <= index < len(loot_options):
|
||||
item_obj, count = loot_options[index]
|
||||
self.player.inventory.add_item(item_obj, count)
|
||||
self.current_scene.remove_lootable_item(container_name, item_obj, count)
|
||||
print(f"You took the {item_obj.name}.")
|
||||
# Re-display options if there's still loot
|
||||
if any(c > 0 for c in container_items.values()):
|
||||
print(f"Remaining items in {container_name}:")
|
||||
for i, (item_obj, count) in enumerate(container_items.items()):
|
||||
if count > 0:
|
||||
print(f"{i+1}) {item_obj.name} (x{count})")
|
||||
print(f"{len(loot_options) + 1}) Take all")
|
||||
print(f"{len(loot_options) + 2}) Leave")
|
||||
else:
|
||||
print(f"The {container_name} is now empty.")
|
||||
break
|
||||
selection_index = int(choice) - 1
|
||||
if 0 <= selection_index < len(loot_list):
|
||||
item_obj, current_count = loot_list[selection_index]
|
||||
take_count_input = input(
|
||||
f"How many {item_obj.name} will you take? (enter 'all' or a number)\n>> ").lower().strip()
|
||||
|
||||
if take_count_input == "all":
|
||||
take_count = current_count
|
||||
else:
|
||||
print("Invalid selection.")
|
||||
try:
|
||||
take_count = int(take_count_input)
|
||||
except ValueError:
|
||||
print("Invalid amount. Please enter 'all' or a number.")
|
||||
continue
|
||||
|
||||
if 0 < take_count <= current_count:
|
||||
self.player.inventory.add_item(item_obj, take_count)
|
||||
container.remove_item(item_obj, take_count)
|
||||
print(f"You took {take_count} {item_obj.name}.")
|
||||
|
||||
if item_obj not in container.contents:
|
||||
loot_list = list(container.contents.items())
|
||||
|
||||
if not container.contents:
|
||||
print(f"The {current_name} is now empty.")
|
||||
if not container.has_items():
|
||||
del self.current_scene.lootable_items[current_name]
|
||||
return
|
||||
else:
|
||||
print("Invalid amount or not enough items.")
|
||||
else:
|
||||
print("Invalid input. Please enter a number, 'take all', or 'leave'.")
|
||||
print("Invalid selection. Please choose a valid item number, 'take all', or 'leave'.")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number, 'take all', or 'leave'.")
|
||||
else:
|
||||
print("You don't see a container like that here to loot.")
|
||||
print("Invalid selection. Please enter a number, 'take all', or 'leave'.")
|
||||
|
||||
|
||||
# Initialize the game engine
|
||||
@@ -245,15 +356,32 @@ def main_game_loop():
|
||||
The main loop for the game, handling character creation and continuous gameplay.
|
||||
"""
|
||||
# Character Creation
|
||||
name, gender, age, race, job = startGame()
|
||||
name, gender, age, race, class_, pre_allocated_stats = startGame()
|
||||
|
||||
# Create player instance
|
||||
player = Player(name, gender, age, race, job)
|
||||
player = Player(name, gender, age, race, class_)
|
||||
game_engine.set_player(player) # Set the player in the game engine
|
||||
|
||||
# Display initial character summary
|
||||
player.new_char() # This calls allocation, which is interactive.
|
||||
if pre_allocated_stats is None:
|
||||
# For 'Create New Character' or 'Quick Set Up'
|
||||
game_engine.player.new_char()
|
||||
else:
|
||||
# For 'Pre-generated Character'
|
||||
game_engine.player.stats = pre_allocated_stats
|
||||
game_engine.player.getModifier()
|
||||
game_engine.player.set_stats_class() # Still need to set HP based on class and Con modifier
|
||||
game_engine.player.current_stats() # Display stats after setting them
|
||||
print(' ')
|
||||
game_engine.player.inventory.current_equipment()
|
||||
print(' ')
|
||||
game_engine.player.inventory.current_inventory()
|
||||
print(' ')
|
||||
if game_engine.player.class_.is_caster:
|
||||
game_engine.player.print_spellbook()
|
||||
print(' ')
|
||||
|
||||
wait()
|
||||
print("\nYour adventure begins...")
|
||||
game_engine.display_current_scene() # Display the starting scene
|
||||
|
||||
@@ -266,10 +394,12 @@ def main_game_loop():
|
||||
get_instructions()
|
||||
elif command == "scene":
|
||||
game_engine.display_current_scene()
|
||||
elif command == "look_around":
|
||||
game_engine.look_around()
|
||||
elif command == "inventory":
|
||||
game_engine.player.inventory.current_inventory()
|
||||
elif command == "equipment":
|
||||
Inventory.current_equipment() # This is a static method on Inventory class
|
||||
game_engine.player.inventory.current_equipment()
|
||||
elif command == "stats":
|
||||
game_engine.player.current_stats()
|
||||
elif command == "hp":
|
||||
@@ -294,9 +424,32 @@ def main_game_loop():
|
||||
game_engine.player.drop_item(obj1)
|
||||
else:
|
||||
print("What would you like to drop?")
|
||||
elif command.startswith("go "):
|
||||
direction = command.split(" ", 1)[1]
|
||||
elif command == "go":
|
||||
direction = obj1
|
||||
game_engine.move_to_scene(direction)
|
||||
elif command == "spells":
|
||||
game_engine.player.print_spellbook()
|
||||
elif command == "cast":
|
||||
if obj1:
|
||||
game_engine.player.cast_spell(obj1)
|
||||
else:
|
||||
print("What would you like to cast?")
|
||||
elif command == "prepare":
|
||||
if obj1:
|
||||
game_engine.player.prepare_spell(obj1)
|
||||
else:
|
||||
print("What would you like to prepare?")
|
||||
elif command == "unprepare":
|
||||
if obj1:
|
||||
game_engine.player.unprepare_spell(obj1)
|
||||
else:
|
||||
print("What would you like to unprepare?")
|
||||
elif command == "rest":
|
||||
game_engine.player.long_rest()
|
||||
elif command == "weight":
|
||||
game_engine.player.carry_report()
|
||||
elif command == "currency":
|
||||
game_engine.player.currency_report()
|
||||
elif command == "quit":
|
||||
print("Thanks for playing!")
|
||||
break
|
||||
|
||||
28
pc/README.md
Normal file
28
pc/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# pc/ — Kanjin Windows Standalone
|
||||
|
||||
Build a single `.exe` that players can double-click to play — no Python, no IDE, no dependencies.
|
||||
|
||||
## Build it
|
||||
|
||||
```batch
|
||||
cd pc
|
||||
build.bat
|
||||
```
|
||||
|
||||
Deliver `pc\Kanjin.exe` (~35 MB). The player double-clicks it, a console window opens, the game starts.
|
||||
|
||||
## What's inside
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `build.bat` | Windows batch script — runs PyInstaller with the right flags |
|
||||
| `build.sh` | Linux/macOS equivalent (for cross-builds) |
|
||||
| `kanjin.ico` | Application icon embedded in the .exe |
|
||||
| `generate_icon.py` | Creates `kanjin.ico` from a solid-colour PNG (run once) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ installed on the **build** machine
|
||||
- `pip install pyinstaller` (installed automatically by build.bat)
|
||||
|
||||
The **player's** machine needs nothing — the .exe is fully self-contained.
|
||||
20
pc/build.bat
Normal file
20
pc/build.bat
Normal file
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
echo Installing PyInstaller...
|
||||
pip install pyinstaller
|
||||
|
||||
echo Building Kanjin.exe...
|
||||
REM --onefile: single executable
|
||||
REM --name: output filename
|
||||
REM --icon: application icon
|
||||
REM --distpath=. : place .exe in this directory (pc/)
|
||||
REM --workpath: temp build folder (cleaned up)
|
||||
python -m PyInstaller --onefile --name Kanjin --icon=kanjin.ico --distpath=. --workpath=build_temp ..\main.py
|
||||
|
||||
echo Cleaning up...
|
||||
rd /s /q build_temp __pycache__ 2>nul
|
||||
del Kanjin.spec 2>nul
|
||||
|
||||
echo.
|
||||
echo Done! Deliver pc\Kanjin.exe to players.
|
||||
pause
|
||||
19
pc/build.sh
Normal file
19
pc/build.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-platform PyInstaller build (run from pc/ on any OS)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
pip install pyinstaller
|
||||
|
||||
python -m PyInstaller \
|
||||
--onefile \
|
||||
--name Kanjin \
|
||||
--icon=kanjin.ico \
|
||||
--distpath=. \
|
||||
--workpath=build_temp \
|
||||
../main.py
|
||||
|
||||
rm -rf build_temp __pycache__ Kanjin.spec
|
||||
|
||||
echo ""
|
||||
echo "Done! Deliver pc/Kanjin.exe to players."
|
||||
35
pc/generate_icon.py
Normal file
35
pc/generate_icon.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Generate kanjin.ico from a solid-colour PNG embedded in an ICO container."""
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _png_chunk(chunk_type, data):
|
||||
c = chunk_type + data
|
||||
crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
return struct.pack(">I", len(data)) + c + crc
|
||||
|
||||
|
||||
def solid_png_bytes(width, height, r, g, b):
|
||||
header = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
||||
raw = bytearray()
|
||||
for _ in range(height):
|
||||
raw.append(0)
|
||||
raw.extend(bytes([r, g, b]) * width)
|
||||
idat = _png_chunk(b"IDAT", zlib.compress(bytes(raw)))
|
||||
iend = _png_chunk(b"IEND", b"")
|
||||
return header + ihdr + idat + iend
|
||||
|
||||
|
||||
def make_ico(png_data):
|
||||
header = struct.pack("<HHH", 0, 1, 1)
|
||||
entry = struct.pack("<BBBBHHII", 0, 0, 0, 0, 1, 32, len(png_data), 22)
|
||||
return header + entry + png_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dst = Path(__file__).parent / "kanjin.ico"
|
||||
png = solid_png_bytes(256, 256, 0x1A, 0x1A, 0x2E)
|
||||
dst.write_bytes(make_ico(png))
|
||||
print(f"Created {dst.name}")
|
||||
BIN
pc/kanjin.ico
Normal file
BIN
pc/kanjin.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
384
player.py
384
player.py
@@ -1,20 +1,38 @@
|
||||
import re
|
||||
from random import randint
|
||||
from enum_list import DamageType, DamageMod, ItemType, Slots # Import specific enums
|
||||
from enum_list import DamageType, DamageMod, ItemType, Slots, SaveType
|
||||
from spell_list import SPELL_LISTS, get_spell_by_name
|
||||
|
||||
|
||||
def roll_dice(dice_str):
|
||||
"""Rolls a dice string like '1d6', '3d4+3', '2d10-1' and returns the total."""
|
||||
if not dice_str:
|
||||
return 0
|
||||
match = re.match(r"(\d+)d(\d+)([+-]\d+)?", dice_str.strip())
|
||||
if not match:
|
||||
return 0
|
||||
num, size, modifier = match.groups()
|
||||
total = sum(randint(1, int(size)) for _ in range(int(num)))
|
||||
if modifier:
|
||||
total += int(modifier)
|
||||
return total
|
||||
|
||||
# Define Item classes here, as they are fundamental building blocks
|
||||
class Item:
|
||||
"""The base class for all items"""
|
||||
def __init__(self, name, description, value, magical, itemType, attunement):
|
||||
def __init__(self, name, description, value, magical, itemType, attunement, weight=0):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.value = value
|
||||
self.magical = magical
|
||||
self.itemType = itemType
|
||||
self.attunement = attunement
|
||||
self.weight = weight # in pounds, per SRD carrying capacity rules
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n"
|
||||
|
||||
|
||||
class Money(Item):
|
||||
"""The currency item used in the world of Kanjin"""
|
||||
def __init__(self, name, amt, magical, ItemType):
|
||||
@@ -28,22 +46,26 @@ class Money(Item):
|
||||
value=self.amt,
|
||||
magical=self.magical,
|
||||
itemType=ItemType.Money,
|
||||
attunement=False)
|
||||
attunement=False,
|
||||
weight=0.02) # SRD: 50 coins weigh 1 lb, regardless of denomination
|
||||
|
||||
|
||||
class Weapon(Item):
|
||||
"""The base class for all weapons"""
|
||||
def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg,
|
||||
dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement):
|
||||
def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg, light, finesse,
|
||||
dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement, weight=0):
|
||||
self.slot = slot
|
||||
self.damage1H = damage1H
|
||||
self.damage2H = damage2H
|
||||
self.versatiledmg = versatiledmg
|
||||
self.light = light
|
||||
self.finesse = finesse
|
||||
self.dmgType = dmgType
|
||||
self.dmgMod = dmgMod
|
||||
self.versatile = versatile
|
||||
self.thrown = thrown
|
||||
self.itemType = ItemType
|
||||
super().__init__(name, description, value, magical, ItemType, attunement)
|
||||
super().__init__(name, description, value, magical, ItemType, attunement, weight)
|
||||
|
||||
def __str__(self):
|
||||
if self.damage2H is None and self.damage1H is not None:
|
||||
@@ -77,16 +99,18 @@ class Weapon(Item):
|
||||
f"Magical: {self.magical}\n" \
|
||||
f"Attunement: {'Required' if self.attunement else 'Not Required'}"
|
||||
|
||||
|
||||
class Armor(Item):
|
||||
"""The base class for all armor"""
|
||||
def __init__(self, name, description, value, slot, grade, ac, disadvantage, dmgRes, ItemType, magical, attunement):
|
||||
def __init__(self, name, description, value, slot, grade, ac, disadvantage, dmgRes, ItemType, magical,
|
||||
attunement, weight=0):
|
||||
self.slot = slot
|
||||
self.grade = grade
|
||||
self.ac = ac
|
||||
self.stealthDis = disadvantage
|
||||
self.dmgRes = dmgRes
|
||||
self.itemType = ItemType
|
||||
super().__init__(name, description, value, magical, ItemType, attunement)
|
||||
super().__init__(name, description, value, magical, ItemType, attunement, weight)
|
||||
|
||||
def __repr__(self):
|
||||
if self.stealthDis:
|
||||
@@ -116,7 +140,7 @@ class Inventory:
|
||||
Slots.OffHand: None,
|
||||
Slots.TwoHanded: None,
|
||||
Slots.Helm: None,
|
||||
Slots.Chest: None,
|
||||
Slots.Armor: None,
|
||||
Slots.Wrists: None,
|
||||
Slots.Feet: None,
|
||||
Slots.Neck: None,
|
||||
@@ -143,10 +167,11 @@ class Inventory:
|
||||
|
||||
def add_item(self, item, count=1, silent=False):
|
||||
"""Adds an item to the inventory."""
|
||||
if item in self.items:
|
||||
self.items[item]["Count"] += count
|
||||
existing = next((e for e in self.items if e.name.lower() == item.name.lower()), None)
|
||||
if existing:
|
||||
self.items[existing]["Count"] += count
|
||||
if not silent:
|
||||
print(f"Added {count} more {item.name}. Total: {self.items[item]['Count']}.")
|
||||
print(f"Added {count} more {existing.name}. Total: {self.items[existing]['Count']}.")
|
||||
else:
|
||||
self.items[item] = {"Count": count, "object": item.itemType}
|
||||
if not silent:
|
||||
@@ -173,11 +198,11 @@ class Inventory:
|
||||
print(f'You are currently carrying:')
|
||||
found_un_equipped = False
|
||||
for item_obj, item_data in self.items.items():
|
||||
# Check if the item is in the backpack AND not currently equipped in any slot
|
||||
if item_obj not in self.equipped_items.values():
|
||||
equipped_count = sum(1 for eq in self.equipped_items.values() if eq is item_obj)
|
||||
backpack_count = item_data["Count"] - equipped_count
|
||||
if backpack_count > 0:
|
||||
found_un_equipped = True
|
||||
count = item_data["Count"]
|
||||
print(f'{item_obj.name} x {count}\n'
|
||||
print(f'{item_obj.name} x {backpack_count}\n'
|
||||
f' {item_obj.description}\n')
|
||||
if not found_un_equipped:
|
||||
print(" Your rucksack is empty.")
|
||||
@@ -294,6 +319,65 @@ class Inventory:
|
||||
attune_count += 1
|
||||
self.equipped_items[Slots.Attunement] = attune_count
|
||||
|
||||
# ----- Weight / Encumbrance (SRD carrying capacity) -----
|
||||
def total_weight(self):
|
||||
"""Sums the weight of everything carried, including equipped gear."""
|
||||
return sum(item.weight * data["Count"] for item, data in self.items.items())
|
||||
|
||||
def carrying_capacity(self, strength_score):
|
||||
"""SRD: your carrying capacity is your Strength score multiplied by 15."""
|
||||
return strength_score * 15
|
||||
|
||||
def encumbrance_status(self, strength_score):
|
||||
"""Returns (status_str, weight, capacity) using the SRD variant encumbrance thresholds."""
|
||||
weight = self.total_weight()
|
||||
capacity = self.carrying_capacity(strength_score)
|
||||
heavily_encumbered_at = strength_score * 10
|
||||
encumbered_at = strength_score * 5
|
||||
|
||||
if weight > capacity:
|
||||
status = "Over Capacity! You cannot carry this much."
|
||||
elif weight > heavily_encumbered_at:
|
||||
status = "Heavily Encumbered (speed -20 ft, disadvantage on Strength/Dexterity/Constitution checks, attacks, and saves)"
|
||||
elif weight > encumbered_at:
|
||||
status = "Encumbered (speed -10 ft)"
|
||||
else:
|
||||
status = "Unencumbered"
|
||||
return status, weight, capacity
|
||||
|
||||
def print_carry_report(self, strength_score):
|
||||
"""Prints a human-readable summary of current carrying weight/capacity."""
|
||||
status, weight, capacity = self.encumbrance_status(strength_score)
|
||||
print(f"Carrying {weight:.2f} lb / {capacity} lb capacity.")
|
||||
print(f"Status: {status}")
|
||||
|
||||
# ----- Currency -----
|
||||
def _coin_counts(self):
|
||||
"""Returns a dict of coin name -> count currently held, e.g. {'Gold': 12}."""
|
||||
counts = {}
|
||||
for item, data in self.items.items():
|
||||
if item.itemType == ItemType.Money:
|
||||
counts[item.name] = data["Count"]
|
||||
return counts
|
||||
|
||||
def total_currency_in_gp(self):
|
||||
"""Converts all held coinage to a single gold-piece value (SRD: 1gp = 10sp = 100cp)."""
|
||||
coins = self._coin_counts()
|
||||
gp = coins.get("Gold", 0)
|
||||
sp = coins.get("Silver", 0)
|
||||
cp = coins.get("Copper", 0)
|
||||
return gp + (sp / 10) + (cp / 100)
|
||||
|
||||
def print_currency(self):
|
||||
"""Prints a breakdown of held coinage and its total gold-piece value."""
|
||||
coins = self._coin_counts()
|
||||
if not coins:
|
||||
print("You have no coins.")
|
||||
return
|
||||
parts = [f"{count} {name}" for name, count in coins.items() if count > 0]
|
||||
print(f"You are carrying: {', '.join(parts) if parts else 'no coins'}.")
|
||||
print(f"Total value: {self.total_currency_in_gp():.2f} gp")
|
||||
|
||||
|
||||
# --- Item Instances (Moved here for clarity, but still defined once) ---
|
||||
# Different types of coins
|
||||
@@ -310,30 +394,36 @@ rock = Weapon(
|
||||
damage1H='1d6',
|
||||
damage2H=None,
|
||||
versatiledmg=None,
|
||||
light=True,
|
||||
finesse=False,
|
||||
dmgType=DamageType.Bludgeoning,
|
||||
dmgMod=DamageMod.Strength,
|
||||
versatile=False,
|
||||
thrown=True,
|
||||
magical=False,
|
||||
ItemType=ItemType.Weapon,
|
||||
attunement=False # Rocks typically aren't attuned
|
||||
attunement=False,
|
||||
weight=2
|
||||
)
|
||||
|
||||
dagger = Weapon(
|
||||
name="Dagger",
|
||||
description="Pointy stabby-stab",
|
||||
value=None,
|
||||
slot=Slots.MainHand, # Can be main hand or off hand
|
||||
damage1H='1d4', # Daggers are usually 1d4
|
||||
slot=Slots.MainHand,
|
||||
damage1H='1d4',
|
||||
damage2H=None,
|
||||
versatiledmg=None, # Daggers are not versatile in the D&D sense (they are light, finesse)
|
||||
dmgType=DamageType.Piercing, # Changed to piercing
|
||||
versatiledmg=None,
|
||||
light=True,
|
||||
finesse=True,
|
||||
dmgType=DamageType.Piercing,
|
||||
dmgMod=DamageMod.Dexterity,
|
||||
versatile=False, # Changed to False, as D&D versatile means 1H or 2H damage
|
||||
versatile=False,
|
||||
thrown=True,
|
||||
magical=False,
|
||||
ItemType=ItemType.Weapon,
|
||||
attunement=False
|
||||
attunement=False,
|
||||
weight=1
|
||||
)
|
||||
|
||||
polearm = Weapon(
|
||||
@@ -344,27 +434,31 @@ polearm = Weapon(
|
||||
damage1H=None,
|
||||
damage2H="1d10", # Polearms are typically 1d10
|
||||
versatiledmg=None,
|
||||
light=False,
|
||||
finesse=False,
|
||||
dmgType=DamageType.Piercing,
|
||||
dmgMod=DamageMod.Strength,
|
||||
versatile=False,
|
||||
thrown=False, # Polearms are not typically thrown
|
||||
magical=False,
|
||||
ItemType=ItemType.Weapon,
|
||||
attunement=False
|
||||
attunement=False,
|
||||
weight=6
|
||||
)
|
||||
|
||||
tornRags = Armor(
|
||||
name="Torn Rags",
|
||||
description="A ripped and worn-out outfit.",
|
||||
value=None,
|
||||
slot=Slots.Chest,
|
||||
slot=Slots.Armor,
|
||||
grade="Light",
|
||||
ac=10, # Base AC for light armor without proficiency is 10 + Dex mod
|
||||
disadvantage=False, # Light armor usually doesn't give disadvantage
|
||||
dmgRes="None",
|
||||
magical=False,
|
||||
ItemType=ItemType.Armor,
|
||||
attunement=False
|
||||
attunement=False,
|
||||
weight=2
|
||||
)
|
||||
|
||||
paper = Item(
|
||||
@@ -373,19 +467,22 @@ paper = Item(
|
||||
value=None,
|
||||
magical=False,
|
||||
itemType=ItemType.Item,
|
||||
attunement=False
|
||||
attunement=False,
|
||||
weight=0
|
||||
)
|
||||
|
||||
|
||||
class Player:
|
||||
"""Character Creation"""
|
||||
def __init__(self, name, gender, age, race, job):
|
||||
def __init__(self, name, gender, age, race, class_):
|
||||
# Identity
|
||||
self.name = name
|
||||
self.gender = gender
|
||||
self.age = age
|
||||
self.race = race
|
||||
self.job = job
|
||||
# `class_` (not `class`) because `class` is a reserved Python keyword.
|
||||
# See the NOTE ON NAMING comment at the top of class_list.py for the full convention.
|
||||
self.class_ = class_
|
||||
|
||||
# Defence
|
||||
self.ac = 0 # This will be calculated based on equipped armor
|
||||
@@ -412,6 +509,19 @@ class Player:
|
||||
# Equipment and Inventory
|
||||
self.inventory = Inventory() # Each player gets their own inventory instance
|
||||
|
||||
# Proficiency bonus (SRD: +2 at levels 1-4)
|
||||
self.proficiency_bonus = 2
|
||||
|
||||
# --- Spellcasting (SRD) ---
|
||||
# None of these are populated until initialize_spellcasting() runs, since they
|
||||
# depend on ability modifiers, which are calculated later during character creation.
|
||||
self.spellcasting_ability = class_.spellcasting_ability # e.g. "Intelligence", or None
|
||||
self.cantrips_known = [] # list of Spell objects, always available, no slot cost
|
||||
self.spells_known = [] # Wizard: spellbook contents. Cleric: full accessible list.
|
||||
self.spells_prepared = [] # subset of spells_known currently prepared/castable
|
||||
self.spell_slots_max = dict(class_.spell_slots) # {level: max_slots}
|
||||
self.spell_slots_current = dict(class_.spell_slots) # {level: slots_remaining}
|
||||
|
||||
# Ability Scores
|
||||
self.stats = {"Strength": 0,
|
||||
"Dexterity": 0,
|
||||
@@ -430,9 +540,10 @@ class Player:
|
||||
# Initial items and equipment (now with silent=True)
|
||||
self.inventory.add_item(rock, 1, silent=True)
|
||||
self.inventory.add_item(tornRags, 1, silent=True)
|
||||
|
||||
# Equip initial items (using the inventory's equip method)
|
||||
self.inventory.equip_item(rock, Slots.MainHand, silent=True)
|
||||
self.inventory.equip_item(tornRags, Slots.Chest, silent=True)
|
||||
self.inventory.equip_item(tornRags, Slots.Armor, silent=True)
|
||||
|
||||
|
||||
def getModifier(self):
|
||||
@@ -528,22 +639,198 @@ class Player:
|
||||
print("As a Dwarf, your constitution increases by 2.")
|
||||
self.stats["Constitution"] += 2
|
||||
|
||||
def set_stats_job(self):
|
||||
"""Applies job-specific stats like hit points."""
|
||||
# Using self.job.name to access the job name from the Job object
|
||||
if self.job.name.lower() == "barbarian":
|
||||
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) # Use job's hitdie range
|
||||
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP
|
||||
def set_stats_class(self):
|
||||
"""Applies class-specific stats like hit points."""
|
||||
# Using self.class_.name to access the class name from the CharacterClass object
|
||||
if self.class_.name.lower() == "barbarian":
|
||||
self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) # Use class's hitdie range
|
||||
self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP
|
||||
self.currentHP = self.maxHP
|
||||
elif self.job.name.lower() == "cleric":
|
||||
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1])
|
||||
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"])
|
||||
elif self.class_.name.lower() == "cleric":
|
||||
self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
|
||||
self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
|
||||
self.currentHP = self.maxHP
|
||||
elif self.job.name.lower() == "wizard":
|
||||
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1])
|
||||
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"])
|
||||
elif self.class_.name.lower() == "wizard":
|
||||
self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
|
||||
self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
|
||||
self.currentHP = self.maxHP
|
||||
|
||||
self.initialize_spellcasting()
|
||||
|
||||
# ----- Magic system (SRD) -----
|
||||
def initialize_spellcasting(self):
|
||||
"""Grants starting cantrips and known/preparable spells for casters. Non-casters no-op."""
|
||||
if not self.class_.is_caster:
|
||||
return
|
||||
|
||||
class_name = self.class_.name
|
||||
class_spells = SPELL_LISTS.get(class_name, {})
|
||||
|
||||
# Cantrips: granted automatically up to the class's cantrips_known count.
|
||||
available_cantrips = class_spells.get(0, [])
|
||||
self.cantrips_known = list(available_cantrips[:self.class_.cantrips_known])
|
||||
|
||||
# Known/preparable leveled spells depend on the class's spellbook style.
|
||||
available_level1 = class_spells.get(1, [])
|
||||
if self.class_.spellbook_style == "spellbook":
|
||||
# Wizard: the whole curated list forms your starting spellbook.
|
||||
self.spells_known = list(available_level1)
|
||||
elif self.class_.spellbook_style == "all_known":
|
||||
# Cleric: you have access to your entire class list, and choose what to prepare.
|
||||
self.spells_known = list(available_level1)
|
||||
|
||||
# Auto-prepare up to your max_prepared_spells() limit so the character is playable
|
||||
# immediately after creation; the player can re-prepare later with 'prepare <spell>'.
|
||||
max_prepared = self.max_prepared_spells()
|
||||
self.spells_prepared = list(self.spells_known[:max_prepared])
|
||||
|
||||
def max_prepared_spells(self):
|
||||
"""SRD: ability modifier + character level (minimum 1)."""
|
||||
if not self.spellcasting_ability:
|
||||
return 0
|
||||
return max(1, self.mods[self.spellcasting_ability] + self.level)
|
||||
|
||||
def spell_save_dc(self):
|
||||
"""SRD: 8 + proficiency bonus + spellcasting ability modifier."""
|
||||
if not self.spellcasting_ability:
|
||||
return None
|
||||
return 8 + self.proficiency_bonus + self.mods[self.spellcasting_ability]
|
||||
|
||||
def spell_attack_bonus(self):
|
||||
"""SRD: proficiency bonus + spellcasting ability modifier."""
|
||||
if not self.spellcasting_ability:
|
||||
return None
|
||||
return self.proficiency_bonus + self.mods[self.spellcasting_ability]
|
||||
|
||||
def print_spellbook(self):
|
||||
"""Prints known cantrips, known/preparable spells, prepared spells, and slots."""
|
||||
if not self.class_.is_caster:
|
||||
print(f"{self.class_.name}s don't cast spells.")
|
||||
return
|
||||
|
||||
print(f"Spellcasting Ability: {self.spellcasting_ability}")
|
||||
print(f"Spell Save DC: {self.spell_save_dc()}")
|
||||
print(f"Spell Attack Bonus: {self.spell_attack_bonus():+}")
|
||||
print(f"\nCantrips Known ({len(self.cantrips_known)}):")
|
||||
for spell in self.cantrips_known:
|
||||
print(f" - {spell.name}")
|
||||
|
||||
style_label = "Spellbook" if self.class_.spellbook_style == "spellbook" else "Class Spell List"
|
||||
print(f"\n{style_label} ({len(self.spells_known)}):")
|
||||
for spell in self.spells_known:
|
||||
prepared_tag = " [Prepared]" if spell in self.spells_prepared else ""
|
||||
print(f" - {spell.name} (Level {spell.level}){prepared_tag}")
|
||||
|
||||
print(f"\nPrepared Spells: {len(self.spells_prepared)}/{self.max_prepared_spells()}")
|
||||
print("Spell Slots:")
|
||||
for level in sorted(self.spell_slots_max):
|
||||
print(f" Level {level}: {self.spell_slots_current.get(level, 0)}/{self.spell_slots_max[level]}")
|
||||
|
||||
def prepare_spell(self, spell_name):
|
||||
"""Moves a spell from your known list into your prepared list, if you have room."""
|
||||
spell = next((s for s in self.spells_known if s.name.lower() == spell_name.lower()), None)
|
||||
if not spell:
|
||||
print(f"'{spell_name}' isn't in your {'spellbook' if self.class_.spellbook_style == 'spellbook' else 'class spell list'}.")
|
||||
return
|
||||
if spell in self.spells_prepared:
|
||||
print(f"{spell.name} is already prepared.")
|
||||
return
|
||||
if len(self.spells_prepared) >= self.max_prepared_spells():
|
||||
print(f"You can't prepare any more spells ({self.max_prepared_spells()} max). "
|
||||
f"Unprepare something first.")
|
||||
return
|
||||
self.spells_prepared.append(spell)
|
||||
print(f"Prepared {spell.name}.")
|
||||
|
||||
def unprepare_spell(self, spell_name):
|
||||
"""Removes a spell from your prepared list."""
|
||||
spell = next((s for s in self.spells_prepared if s.name.lower() == spell_name.lower()), None)
|
||||
if not spell:
|
||||
print(f"'{spell_name}' isn't currently prepared.")
|
||||
return
|
||||
self.spells_prepared.remove(spell)
|
||||
print(f"Unprepared {spell.name}.")
|
||||
|
||||
def cast_spell(self, spell_name, slot_level=None):
|
||||
"""
|
||||
Casts a cantrip (free) or a prepared leveled spell (consumes a slot).
|
||||
Returns a result dict describing what happened, or None if the cast failed.
|
||||
"""
|
||||
# Cantrips are always available and never cost a slot.
|
||||
cantrip = next((s for s in self.cantrips_known if s.name.lower() == spell_name.lower()), None)
|
||||
if cantrip:
|
||||
return self._resolve_spell_effect(cantrip, slot_level=0)
|
||||
|
||||
spell = next((s for s in self.spells_prepared if s.name.lower() == spell_name.lower()), None)
|
||||
if not spell:
|
||||
print(f"You don't have '{spell_name}' prepared, and it isn't a cantrip you know.")
|
||||
return None
|
||||
|
||||
# Default to casting at its base level if no upcast level is specified.
|
||||
cast_level = slot_level or spell.level
|
||||
if cast_level < spell.level:
|
||||
print(f"{spell.name} requires at least a level {spell.level} slot.")
|
||||
return None
|
||||
if self.spell_slots_current.get(cast_level, 0) <= 0:
|
||||
print(f"You have no level {cast_level} spell slots remaining.")
|
||||
return None
|
||||
|
||||
self.spell_slots_current[cast_level] -= 1
|
||||
return self._resolve_spell_effect(spell, slot_level=cast_level)
|
||||
|
||||
def _resolve_spell_effect(self, spell, slot_level):
|
||||
"""Rolls damage/healing for a spell and prints/returns the outcome."""
|
||||
result = {"spell": spell.name, "damage": 0, "heal": 0, "dmg_type": spell.dmg_type,
|
||||
"save": spell.save, "save_dc": self.spell_save_dc()}
|
||||
|
||||
# Determine upcast bonus dice, if any.
|
||||
extra_levels = max(0, slot_level - spell.level) if spell.level > 0 else 0
|
||||
|
||||
if spell.damage:
|
||||
damage = roll_dice(spell.damage)
|
||||
if spell.scaling_damage:
|
||||
if "per_slot_level" in spell.scaling_damage and extra_levels:
|
||||
for _ in range(extra_levels):
|
||||
damage += roll_dice(spell.scaling_damage["per_slot_level"])
|
||||
elif self.level in spell.scaling_damage:
|
||||
# Cantrip scaling by character level (e.g. Fire Bolt at level 5/11/17)
|
||||
damage = roll_dice(spell.scaling_damage.get(self.level, spell.damage))
|
||||
else:
|
||||
for threshold in sorted([t for t in spell.scaling_damage if isinstance(t, int)], reverse=True):
|
||||
if self.level >= threshold:
|
||||
damage = roll_dice(spell.scaling_damage[threshold])
|
||||
break
|
||||
result["damage"] = damage
|
||||
|
||||
if spell.heal:
|
||||
heal = roll_dice(spell.heal)
|
||||
if spell.scaling_damage and "per_slot_level" in spell.scaling_damage and extra_levels:
|
||||
for _ in range(extra_levels):
|
||||
heal += roll_dice(spell.scaling_damage["per_slot_level"])
|
||||
result["heal"] = heal
|
||||
|
||||
# Print a readable summary.
|
||||
print(f"\nYou cast {spell.name}!")
|
||||
if spell.save != SaveType.NONE:
|
||||
print(f"Target must make a DC {result['save_dc']} {spell.save.name} saving throw.")
|
||||
elif spell.damage:
|
||||
atk_bonus = self.spell_attack_bonus()
|
||||
print(f"Spell attack roll: 1d20{atk_bonus:+} vs target AC.")
|
||||
if result["damage"]:
|
||||
print(f"Damage: {result['damage']} {spell.dmg_type.name if spell.dmg_type else ''}".rstrip())
|
||||
if result["heal"]:
|
||||
print(f"Healing: {result['heal']} HP")
|
||||
if not spell.damage and not spell.heal:
|
||||
print(spell.description)
|
||||
|
||||
return result
|
||||
|
||||
def long_rest(self):
|
||||
"""Restores HP to max and refills all spell slots."""
|
||||
self.currentHP = self.maxHP
|
||||
self.spell_slots_current = dict(self.spell_slots_max)
|
||||
print("You take a long rest. HP and spell slots are fully restored.")
|
||||
|
||||
def current_stats(self):
|
||||
"""Prints a display of the user's current statistics."""
|
||||
print(f'\nYour current stats are:')
|
||||
@@ -572,18 +859,29 @@ class Player:
|
||||
self.allocation() # 1. Sets base self.stats from rolls.
|
||||
self.set_stats_race() # 2. Applies racial bonuses to self.stats.
|
||||
self.getModifier() # 3. Calculates ALL self.mods based on the FINAL self.stats (base + racial).
|
||||
self.set_stats_job() # 4. Calculates maxHP using the now-accurate self.mods["Constitution"].
|
||||
self.set_stats_class() # 4. Calculates maxHP using the now-accurate self.mods["Constitution"].
|
||||
self.current_stats() # 5. Displays the final stats.
|
||||
print(' ')
|
||||
self.inventory.current_equipment() # Corrected to call instance method
|
||||
print(' ')
|
||||
self.inventory.current_inventory()
|
||||
print(' ')
|
||||
if self.class_.is_caster:
|
||||
self.print_spellbook()
|
||||
print(' ')
|
||||
|
||||
def health_check(self):
|
||||
"""Print out current/max HP"""
|
||||
print(f'You have {self.currentHP}/{self.maxHP} HP.')
|
||||
|
||||
def carry_report(self):
|
||||
"""Print current weight carried vs. carrying capacity, and encumbrance status."""
|
||||
self.inventory.print_carry_report(self.stats["Strength"])
|
||||
|
||||
def currency_report(self):
|
||||
"""Print current coinage and its total gold-piece value."""
|
||||
self.inventory.print_currency()
|
||||
|
||||
def take_damage(self, DMGtype, size):
|
||||
"""Define the damage type and dice size"""
|
||||
damage = randint(1, size)
|
||||
|
||||
10
race_list.py
10
race_list.py
@@ -39,7 +39,7 @@ class Elf(Race):
|
||||
speed = {
|
||||
'Walking': 30
|
||||
}
|
||||
darkvision = [30, 60]
|
||||
darkvision = 60
|
||||
language = ["Common", "Elvish"]
|
||||
advantage = []
|
||||
resistance = []
|
||||
@@ -155,6 +155,8 @@ class Human(Race):
|
||||
language, advantage, resistance, proficiency, traits)
|
||||
|
||||
|
||||
Elf = Elf()
|
||||
Dwarf = Dwarf()
|
||||
Human = Human()
|
||||
RACES = {
|
||||
"Elf": Elf(),
|
||||
"Dwarf": Dwarf(),
|
||||
"Human": Human(),
|
||||
}
|
||||
|
||||
97
scene.py
97
scene.py
@@ -5,6 +5,36 @@ sys.path.append('.')
|
||||
from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1
|
||||
from enum_list import *
|
||||
|
||||
class Container:
|
||||
def __init__(self, name, description, lock_dc=None, trap_dc=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.lock_dc = lock_dc
|
||||
self.trap_dc = trap_dc
|
||||
self.trap_disarmed = False
|
||||
self.contents = {}
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
return self.lock_dc is not None
|
||||
|
||||
@property
|
||||
def is_trapped(self):
|
||||
return self.trap_dc is not None
|
||||
|
||||
def add_item(self, item_obj, count=1):
|
||||
self.contents[item_obj] = self.contents.get(item_obj, 0) + count
|
||||
|
||||
def remove_item(self, item_obj, count=1):
|
||||
if item_obj in self.contents:
|
||||
self.contents[item_obj] -= count
|
||||
if self.contents[item_obj] <= 0:
|
||||
del self.contents[item_obj]
|
||||
|
||||
def has_items(self):
|
||||
return bool(self.contents)
|
||||
|
||||
|
||||
class Scene:
|
||||
"""
|
||||
Represents a single location or area in the game.
|
||||
@@ -45,15 +75,12 @@ class Scene:
|
||||
return self.exits.get(direction.lower())
|
||||
|
||||
def add_lootable_item(self, container_name, item_obj, count=1):
|
||||
"""
|
||||
Adds an item to a lootable container within the scene.
|
||||
:param container_name: The name of the container (e.g., "chest", "goblin corpse").
|
||||
:param item_obj: The Item object to add.
|
||||
:param count: The quantity of the item.
|
||||
"""
|
||||
if container_name not in self.lootable_items:
|
||||
self.lootable_items[container_name] = {}
|
||||
self.lootable_items[container_name][item_obj] = self.lootable_items[container_name].get(item_obj, 0) + count
|
||||
self.lootable_items[container_name] = Container(container_name, "")
|
||||
self.lootable_items[container_name].add_item(item_obj, count)
|
||||
|
||||
def add_container(self, container):
|
||||
self.lootable_items[container.name] = container
|
||||
|
||||
def add_available_item(self, item_obj, count=1):
|
||||
"""
|
||||
@@ -75,27 +102,39 @@ class Scene:
|
||||
del self.available_items[item_obj]
|
||||
|
||||
def remove_lootable_item(self, container_name, item_obj, count=1):
|
||||
if container_name in self.lootable_items:
|
||||
self.lootable_items[container_name].remove_item(item_obj, count)
|
||||
if not self.lootable_items[container_name].has_items():
|
||||
del self.lootable_items[container_name]
|
||||
|
||||
def add_corpse(self, enemy_name, items):
|
||||
"""Creates a lootable corpse from a defeated enemy.
|
||||
Multiple corpses of the same enemy are numbered: 'spider corpse', 'spider corpse 2', etc.
|
||||
items: dict of {item_obj: count} or iterable of (item_obj, count) pairs.
|
||||
"""
|
||||
Removes a specified count of an item from a lootable container in the scene.
|
||||
:param container_name: The name of the container.
|
||||
:param item_obj: The Item object to remove.
|
||||
:param count: The quantity to remove.
|
||||
"""
|
||||
if container_name in self.lootable_items and item_obj in self.lootable_items[container_name]:
|
||||
self.lootable_items[container_name][item_obj] -= count
|
||||
if self.lootable_items[container_name][item_obj] <= 0:
|
||||
del self.lootable_items[container_name][item_obj]
|
||||
if not self.lootable_items[container_name]: # If container is empty, remove it
|
||||
del self.lootable_items[container_name]
|
||||
base_name = f"{enemy_name.lower()} corpse"
|
||||
name = base_name
|
||||
counter = 1
|
||||
while name in self.lootable_items:
|
||||
counter += 1
|
||||
name = f"{base_name} {counter}"
|
||||
|
||||
corpse = Container(name, f"The lifeless body of a {enemy_name}.")
|
||||
if isinstance(items, dict):
|
||||
for item_obj, count in items.items():
|
||||
corpse.add_item(item_obj, count)
|
||||
else:
|
||||
for item_obj, count in items:
|
||||
corpse.add_item(item_obj, count)
|
||||
self.lootable_items[name] = corpse
|
||||
|
||||
|
||||
# --- Default Scenes ---
|
||||
# Scene 0: Tutorial
|
||||
tutorial = Scene(
|
||||
name="Tutorial",
|
||||
description="Welcome to Kanjin Text RPG!"
|
||||
description="Welcome to Kanjin Text RPG!\n\n"
|
||||
"This is a very simple tutorial to show you the basics of issuing commands.\n"
|
||||
"You can return at anytime where you are asked 'What do you want to do?' by typing 'Enter tutorial'.\n"
|
||||
"Important commands you'll want to know can be found by typing 'Help'.\n\n"
|
||||
"These are the available commands:\n"
|
||||
"Help - Prints this help message.\n"
|
||||
@@ -109,13 +148,13 @@ tutorial = Scene(
|
||||
"Enter cave | house | room - maybe... TBC\n"
|
||||
"Scene or Location - Replays the current area's details\n"
|
||||
"Look around - lists the available items in your scene without displaying the scene description.\n"
|
||||
"Examine | Open | Loot *object* - Provides details of an item, opens a container, or takes an object.\n"
|
||||
"Examine | Open | Loot *object* - Provides details of an item, opens a container, or takes an object."
|
||||
)
|
||||
tutorial.add_available_item(rock, 1)
|
||||
tutorial.add_available_item(paper, 2)
|
||||
tutorial.add_lootable_item("suspicious tree trunk", Item("Test Item",
|
||||
"This is for testing purposes", None,
|
||||
False, ItemType.Item, False), 1)
|
||||
tutorial_trunk = Container("suspicious tree trunk", "A hollow tree trunk with a small gap in the bark.")
|
||||
tutorial_trunk.add_item(Item("Test Item", "This is for testing purposes", None, False, ItemType.Item, False), 1)
|
||||
tutorial.add_container(tutorial_trunk)
|
||||
|
||||
# Scene 1: Starting Clearing
|
||||
starting_clearing = Scene(
|
||||
@@ -126,8 +165,10 @@ starting_clearing = Scene(
|
||||
"You notice a small, weathered wooden chest near a fallen log."
|
||||
)
|
||||
starting_clearing.add_available_item(rock, 3) # Rocks lying on the ground
|
||||
starting_clearing.add_lootable_item("small wooden chest", GP1, 15)
|
||||
starting_clearing.add_lootable_item("small wooden chest", SP1, 20)
|
||||
starting_clearing_chest = Container("small wooden chest", "A small, weathered wooden chest with rusty iron bands.")
|
||||
starting_clearing_chest.add_item(GP1, 15)
|
||||
starting_clearing_chest.add_item(SP1, 20)
|
||||
starting_clearing.add_container(starting_clearing_chest)
|
||||
|
||||
|
||||
# Scene 2: Forest Path
|
||||
@@ -153,6 +194,8 @@ dark_cave_entrance.add_available_item(polearm, 1) # A polearm leaning against th
|
||||
|
||||
|
||||
# Link the scenes together
|
||||
tutorial.add_exit("tutorial", starting_clearing)
|
||||
|
||||
starting_clearing.add_exit("north", forest_path)
|
||||
starting_clearing.add_exit("east", dark_cave_entrance)
|
||||
|
||||
|
||||
221
spell_list.py
Normal file
221
spell_list.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from enum_list import SpellSchool, SaveType, DamageType
|
||||
|
||||
|
||||
class Spell:
|
||||
"""Base class for all spells, built from the D&D 5e SRD."""
|
||||
def __init__(self, name, level, school, casting_time, range_, components,
|
||||
duration, description, classes, damage=None, dmg_type=None,
|
||||
scaling_damage=None, save=SaveType.NONE, heal=None,
|
||||
ritual=False, concentration=False):
|
||||
self.name = name
|
||||
self.level = level # 0 = cantrip
|
||||
self.school = school
|
||||
self.casting_time = casting_time
|
||||
self.range = range_
|
||||
self.components = components
|
||||
self.duration = duration
|
||||
self.description = description
|
||||
self.classes = classes # list of class names that can cast this, e.g. ["Wizard"]
|
||||
self.damage = damage # base damage dice string e.g. "1d10"
|
||||
self.dmg_type = dmg_type
|
||||
self.scaling_damage = scaling_damage # dice added per extra slot level/character level, if any
|
||||
self.save = save
|
||||
self.heal = heal # healing dice string, e.g. "1d8"
|
||||
self.ritual = ritual
|
||||
self.concentration = concentration
|
||||
|
||||
def __repr__(self):
|
||||
level_str = "Cantrip" if self.level == 0 else f"Level {self.level}"
|
||||
tags = []
|
||||
if self.ritual:
|
||||
tags.append("Ritual")
|
||||
if self.concentration:
|
||||
tags.append("Concentration")
|
||||
tag_str = f" ({', '.join(tags)})" if tags else ""
|
||||
return (f"{self.name} - {level_str} {self.school.name}{tag_str}\n"
|
||||
f"=====\n"
|
||||
f"Casting Time: {self.casting_time}\n"
|
||||
f"Range: {self.range}\n"
|
||||
f"Components: {self.components}\n"
|
||||
f"Duration: {self.duration}\n\n"
|
||||
f"{self.description}")
|
||||
|
||||
|
||||
# ----- Wizard Cantrips -----
|
||||
fire_bolt = Spell(
|
||||
name="Fire Bolt", level=0, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="120 feet", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="You hurl a mote of fire at a creature or object within range. Make a ranged "
|
||||
"spell attack against the target. On a hit, the target takes fire damage. "
|
||||
"A flammable object hit by this spell ignites if it isn't being worn or carried.",
|
||||
classes=["Wizard"], damage="1d10", dmg_type=DamageType.Fire,
|
||||
scaling_damage={5: "2d10", 11: "3d10", 17: "4d10"}
|
||||
)
|
||||
|
||||
ray_of_frost = Spell(
|
||||
name="Ray of Frost", level=0, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="60 feet", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="A frigid beam of blue-white light streaks toward a creature within range. "
|
||||
"Make a ranged spell attack against the target. On a hit, it takes cold "
|
||||
"damage and its speed is reduced by 10 feet until the start of your next turn.",
|
||||
classes=["Wizard"], damage="1d8", dmg_type=DamageType.Cold,
|
||||
scaling_damage={5: "2d8", 11: "3d8", 17: "4d8"}
|
||||
)
|
||||
|
||||
mage_hand = Spell(
|
||||
name="Mage Hand", level=0, school=SpellSchool.Conjuration,
|
||||
casting_time="1 action", range_="30 feet", components="V, S",
|
||||
duration="1 minute",
|
||||
description="A spectral, floating hand appears at a point you choose within range. "
|
||||
"The hand lasts for the duration and can manipulate objects, open unlocked "
|
||||
"containers, and carry up to 10 pounds.",
|
||||
classes=["Wizard"]
|
||||
)
|
||||
|
||||
prestidigitation = Spell(
|
||||
name="Prestidigitation", level=0, school=SpellSchool.Transmutation,
|
||||
casting_time="1 action", range_="10 feet", components="V, S",
|
||||
duration="Up to 1 hour",
|
||||
description="This spell is a minor magical trick: create a harmless sensory effect, "
|
||||
"light or snuff a small flame, clean or soil an object, chill/warm/flavor "
|
||||
"a small amount of nonliving material, or make a small mark or symbol appear.",
|
||||
classes=["Wizard"]
|
||||
)
|
||||
|
||||
# ----- Wizard Level 1 Spells -----
|
||||
magic_missile = Spell(
|
||||
name="Magic Missile", level=1, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="120 feet", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="You create three glowing darts of magical force. Each dart hits a creature "
|
||||
"of your choice that you can see within range, dealing force damage. The "
|
||||
"darts all strike simultaneously and automatically hit - no attack roll needed.",
|
||||
classes=["Wizard"], damage="3d4+3", dmg_type=DamageType.Force,
|
||||
scaling_damage={"per_slot_level": "1d4+1"}
|
||||
)
|
||||
|
||||
shield = Spell(
|
||||
name="Shield", level=1, school=SpellSchool.Abjuration,
|
||||
casting_time="1 reaction", range_="Self", components="V, S",
|
||||
duration="1 round",
|
||||
description="An invisible barrier of magical force appears and protects you. Until the "
|
||||
"start of your next turn, you have a +5 bonus to AC and you take no damage "
|
||||
"from Magic Missile.",
|
||||
classes=["Wizard"]
|
||||
)
|
||||
|
||||
burning_hands = Spell(
|
||||
name="Burning Hands", level=1, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="Self (15-foot cone)", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="A thin sheet of flames shoots forth from your outstretched fingertips. Each "
|
||||
"creature in a 15-foot cone must make a Dexterity saving throw. A creature "
|
||||
"takes fire damage on a failed save, or half as much on a successful one.",
|
||||
classes=["Wizard"], damage="3d6", dmg_type=DamageType.Fire, save=SaveType.Dexterity,
|
||||
scaling_damage={"per_slot_level": "1d6"}
|
||||
)
|
||||
|
||||
detect_magic = Spell(
|
||||
name="Detect Magic", level=1, school=SpellSchool.Divination,
|
||||
casting_time="1 action", range_="Self", components="V, S",
|
||||
duration="Concentration, up to 10 minutes",
|
||||
description="For the duration, you sense the presence of magic within 30 feet of you. "
|
||||
"If you sense magic in this way, you can use your action to see a faint "
|
||||
"aura around any visible creature or object that bears magic.",
|
||||
classes=["Wizard", "Cleric"], ritual=True, concentration=True
|
||||
)
|
||||
|
||||
# ----- Cleric Cantrips -----
|
||||
sacred_flame = Spell(
|
||||
name="Sacred Flame", level=0, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="60 feet", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="Flame-like radiance descends on a creature that you can see within range. "
|
||||
"The target must succeed on a Dexterity saving throw or take radiant damage. "
|
||||
"The target gains no benefit from cover for this saving throw.",
|
||||
classes=["Cleric"], damage="1d8", dmg_type=DamageType.Radiant, save=SaveType.Dexterity,
|
||||
scaling_damage={5: "2d8", 11: "3d8", 17: "4d8"}
|
||||
)
|
||||
|
||||
guidance = Spell(
|
||||
name="Guidance", level=0, school=SpellSchool.Divination,
|
||||
casting_time="1 action", range_="Touch", components="V, S",
|
||||
duration="Concentration, up to 1 minute",
|
||||
description="You touch one willing creature. Once before the spell ends, the target "
|
||||
"can roll a d4 and add the number rolled to one ability check of its choice.",
|
||||
classes=["Cleric"], concentration=True
|
||||
)
|
||||
|
||||
spare_the_dying = Spell(
|
||||
name="Spare the Dying", level=0, school=SpellSchool.Necromancy,
|
||||
casting_time="1 action", range_="Touch", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="You touch a living creature that has 0 hit points. The creature becomes "
|
||||
"stable. This spell has no effect on undead or constructs.",
|
||||
classes=["Cleric"]
|
||||
)
|
||||
|
||||
# ----- Cleric Level 1 Spells -----
|
||||
cure_wounds = Spell(
|
||||
name="Cure Wounds", level=1, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="Touch", components="V, S",
|
||||
duration="Instantaneous",
|
||||
description="A creature you touch regains a number of hit points.",
|
||||
classes=["Cleric"], heal="1d8", scaling_damage={"per_slot_level": "1d8"}
|
||||
)
|
||||
|
||||
bless = Spell(
|
||||
name="Bless", level=1, school=SpellSchool.Enchantment,
|
||||
casting_time="1 action", range_="30 feet", components="V, S, M",
|
||||
duration="Concentration, up to 1 minute",
|
||||
description="You bless up to three creatures of your choice within range. Whenever a "
|
||||
"target makes an attack roll or a saving throw before the spell ends, the "
|
||||
"target can add 1d4 to the attack roll or saving throw.",
|
||||
classes=["Cleric"], concentration=True
|
||||
)
|
||||
|
||||
guiding_bolt = Spell(
|
||||
name="Guiding Bolt", level=1, school=SpellSchool.Evocation,
|
||||
casting_time="1 action", range_="120 feet", components="V, S",
|
||||
duration="1 round",
|
||||
description="A flash of light streaks toward a creature of your choice within range. "
|
||||
"Make a ranged spell attack against the target. On a hit, the target takes "
|
||||
"radiant damage, and the next attack roll made against this target before "
|
||||
"the end of your next turn has advantage.",
|
||||
classes=["Cleric"], damage="4d6", dmg_type=DamageType.Radiant,
|
||||
scaling_damage={"per_slot_level": "1d6"}
|
||||
)
|
||||
|
||||
healing_word = Spell(
|
||||
name="Healing Word", level=1, school=SpellSchool.Evocation,
|
||||
casting_time="1 bonus action", range_="60 feet", components="V",
|
||||
duration="Instantaneous",
|
||||
description="A creature of your choice that you can see within range regains hit points.",
|
||||
classes=["Cleric"], heal="1d4", scaling_damage={"per_slot_level": "1d4"}
|
||||
)
|
||||
|
||||
|
||||
# Master lookup: {class_name: {spell_level: [Spell, ...]}}
|
||||
SPELL_LISTS = {
|
||||
"Wizard": {
|
||||
0: [fire_bolt, ray_of_frost, mage_hand, prestidigitation],
|
||||
1: [magic_missile, shield, burning_hands, detect_magic],
|
||||
},
|
||||
"Cleric": {
|
||||
0: [sacred_flame, guidance, spare_the_dying],
|
||||
1: [cure_wounds, bless, guiding_bolt, healing_word, detect_magic],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_spell_by_name(name):
|
||||
"""Looks up a Spell object across all class lists by (case-insensitive) name."""
|
||||
name = name.lower().strip()
|
||||
for class_spells in SPELL_LISTS.values():
|
||||
for level_spells in class_spells.values():
|
||||
for spell in level_spells:
|
||||
if spell.name.lower() == name:
|
||||
return spell
|
||||
return None
|
||||
25
web/Dockerfile
Normal file
25
web/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install server dependencies
|
||||
COPY web/requirements.txt /tmp/
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# ----------
|
||||
FROM base AS builder
|
||||
COPY web/static/generate_icons.py /tmp/
|
||||
RUN python /tmp/generate_icons.py && mv icon-*.png /tmp/
|
||||
|
||||
# ----------
|
||||
FROM base AS runner
|
||||
|
||||
# Game source
|
||||
COPY main.py player.py scene.py function_list.py class_list.py race_list.py enum_list.py spell_list.py ./
|
||||
|
||||
# Web server
|
||||
COPY web/server.py ./web/server.py
|
||||
COPY web/static/index.html web/static/manifest.json web/static/sw.js ./web/static/
|
||||
COPY --from=builder /tmp/icon-192.png /tmp/icon-512.png ./web/static/
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "web.server:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
46
web/README.md
Normal file
46
web/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# web/ — Kanjin Web Server & PWA
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
docker build -t kanjin -f web/Dockerfile .
|
||||
docker run -p 8080:8080 kanjin
|
||||
```
|
||||
|
||||
Open http://localhost:8080 in any browser. On Chrome/Edge you can **"Install as app"** via the address bar icon — removes the URL bar and adds a home-screen icon.
|
||||
|
||||
## Windows standalone .exe
|
||||
|
||||
From the project root, with PyInstaller installed:
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller --onefile --name Kanjin main.py
|
||||
```
|
||||
|
||||
Deliver `dist/Kanjin.exe` — no Python or dependencies needed on the player's machine.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd web
|
||||
python -m pip install -r requirements.txt
|
||||
python generate_icons.py
|
||||
python server.py # starts uvicorn on :8080 with hot-reload
|
||||
```
|
||||
|
||||
Requires Linux or WSL (uses `pty`). The server spawns `main.py` from the parent directory in a pseudo-terminal and streams it to the browser over WebSocket.
|
||||
|
||||
## File layout
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `server.py` | FastAPI app: serves index.html, mounts /static, handles /ws WebSocket |
|
||||
| `static/index.html` | xterm.js in-browser terminal connected over WebSocket |
|
||||
| `static/manifest.json` | PWA manifest — enables Chrome "Install as app" |
|
||||
| `static/sw.js` | Service worker — precaches the shell for offline launch |
|
||||
| `static/generate_icons.py` | Generates placeholder PNG icons (run once) |
|
||||
| `requirements.txt` | Python deps (fastapi, uvicorn) |
|
||||
| `Dockerfile` | Multi-stage Docker build |
|
||||
2
web/requirements.txt
Normal file
2
web/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
89
web/server.py
Normal file
89
web/server.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
try:
|
||||
import pty
|
||||
HAS_PTY = True
|
||||
except ImportError:
|
||||
HAS_PTY = False
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
app = FastAPI(title="Kanjin")
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
if not HAS_PTY:
|
||||
await ws.accept()
|
||||
await ws.send_text("Server must run on Linux (or WSL) with PTY support.\n")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, str(PROJECT_ROOT / "main.py"),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
close_fds=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
async def read_pty():
|
||||
try:
|
||||
while True:
|
||||
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
await ws.send_text(data.decode(errors="replace"))
|
||||
except (ConnectionError, WebSocketDisconnect):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except (ProcessLookupError, asyncio.TimeoutError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
async def write_pty():
|
||||
try:
|
||||
async for message in ws.iter_text():
|
||||
os.write(master_fd, message.encode())
|
||||
except (ConnectionError, WebSocketDisconnect):
|
||||
pass
|
||||
|
||||
await asyncio.gather(read_pty(), write_pty())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=8080, reload=True)
|
||||
30
web/static/generate_icons.py
Normal file
30
web/static/generate_icons.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Generate solid-colour placeholder PNG icons for the PWA manifest."""
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _chunk(chunk_type, data):
|
||||
c = chunk_type + data
|
||||
crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
return struct.pack(">I", len(data)) + c + crc
|
||||
|
||||
|
||||
def solid_png(width, height, r, g, b):
|
||||
header = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
||||
raw = bytearray()
|
||||
for _ in range(height):
|
||||
raw.append(0)
|
||||
raw.extend(bytes([r, g, b]) * width)
|
||||
idat = _chunk(b"IDAT", zlib.compress(bytes(raw)))
|
||||
iend = _chunk(b"IEND", b"")
|
||||
return header + ihdr + idat + iend
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
dst = Path(__file__).parent
|
||||
for size in (192, 512):
|
||||
path = dst / f"icon-{size}.png"
|
||||
path.write_bytes(solid_png(size, size, 0x1A, 0x1A, 0x2E))
|
||||
print(f"Created {path.name} ({size}x{size})")
|
||||
BIN
web/static/icon-192.png
Normal file
BIN
web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
web/static/icon-512.png
Normal file
BIN
web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
53
web/static/index.html
Normal file
53
web/static/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Kanjin — A text adventure RPG based on the D&D 5e SRD">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" href="/static/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<title>Kanjin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; background: #1a1a2e; overflow: hidden; }
|
||||
#terminal { width: 100%; height: 100%; padding: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: { background: '#1a1a2e', foreground: '#e0e0e0', cursor: '#e0e0e0' }
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fit.fit();
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(proto + '//' + location.host + '/ws');
|
||||
|
||||
ws.onopen = () => {
|
||||
term.focus();
|
||||
term.onData(data => ws.send(data));
|
||||
term.onResize(({ cols, rows }) => {
|
||||
ws.send(JSON.stringify({ resize: [cols, rows] }));
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => { term.write(e.data); };
|
||||
ws.onclose = () => { term.write('\r\n\x1b[33m[Connection closed. Refresh to restart.]\x1b[0m\r\n'); };
|
||||
window.addEventListener('resize', () => fit.fit());
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
web/static/manifest.json
Normal file
13
web/static/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Kanjin",
|
||||
"short_name": "Kanjin",
|
||||
"description": "A text adventure RPG based on the D&D 5e SRD",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#1a1a2e",
|
||||
"icons": [
|
||||
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
22
web/static/sw.js
Normal file
22
web/static/sw.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const CACHE = "kanjin-v1";
|
||||
const PRECACHE = ["/", "/static/manifest.json"];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((r) => r || fetch(e.request))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user