Compare commits

..

7 Commits

27 changed files with 1427 additions and 585 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.idea/
__pycache__/
*.pyc
build/
dist/
*.spec

33
AGENTS.md Normal file
View 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).

View File

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

View File

@@ -1,13 +1,30 @@
# Kanjin-Text-RPG # 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. A text adventure RPG based on the Dungeons & Dragons 5e SRD.
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'.
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.

View File

@@ -1,5 +1,14 @@
class Job: # NOTE ON NAMING: this represents a D&D "class" (Barbarian, Cleric, Wizard...), but `class`
def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold): # 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.name = name
self.description = description self.description = description
self.hitdie = hitdie self.hitdie = hitdie
@@ -8,6 +17,20 @@ class Job:
self.skill = skill self.skill = skill
self.gold = gold 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: def __repr__(self) -> str:
profs = "" profs = ""
for key, value in self.proficiency.items(): for key, value in self.proficiency.items():
@@ -24,7 +47,7 @@ class Job:
+ '\n'.join(self.skill[1:]).title() + '\n' + '\n'.join(self.skill[1:]).title() + '\n'
class Barbarian(Job): class Barbarian(CharacterClass):
def __init__(self): def __init__(self):
name = "Barbarian" name = "Barbarian"
description = "A tall human tribesman strides through a blizzard, draped in fur and hefting his axe. " \ 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) super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold)
class Cleric(Job): class Cleric(CharacterClass):
def __init__(self): def __init__(self):
name = "Cleric" name = "Cleric"
description = "Arms and eyes upraised toward the sun and a prayer on his lips, an elf begins to glow with " \ 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" "religion"
] ]
gold = [2, 10] 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): def __init__(self):
name = "Wizard" name = "Wizard"
description = "Clad in the silver robes that denote her station, an elf closes her eyes to shut out the " \ 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" "religion"
] ]
gold = [2, 10] 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() CLASSES = {
Cleric = Cleric() "Barbarian": Barbarian(),
Wizard = Wizard() "Cleric": Cleric(),
"Wizard": Wizard(),
}

View File

@@ -48,3 +48,25 @@ class Slots(Enum):
RightRing = auto() RightRing = auto()
Other = auto() Other = auto()
Attunement = 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)

View File

@@ -4,8 +4,8 @@ sys.path.append('.')
from player import Player # Only import what's necessary from player import Player # Only import what's necessary
from enum_list import ItemType, Slots from enum_list import ItemType, Slots
from race_list import Elf, Dwarf, Human from race_list import RACES
from job_list import Barbarian, Cleric, Wizard from class_list import CLASSES
def wait(): def wait():
@@ -16,8 +16,8 @@ pre_generated_characters = {
"name": "Arion", # Example Name "name": "Arion", # Example Name
"gender": "Male", "gender": "Male",
"age": 25, "age": 25,
"race": Elf, "race": RACES["Elf"],
"job": Wizard, # Based on the High Elf sheet "class_": CLASSES["Wizard"], # Based on the High Elf sheet
"alignment": "lawful good", "alignment": "lawful good",
"description": "A scholarly High Elf skilled in the arcane arts.", "description": "A scholarly High Elf skilled in the arcane arts.",
"stats": { "stats": {
@@ -41,8 +41,8 @@ pre_generated_characters = {
"name": "Brundle", # Example Name "name": "Brundle", # Example Name
"gender": "Female", "gender": "Female",
"age": 35, "age": 35,
"race": Human, "race": RACES["Human"],
"job": Barbarian, # Based on the Human sheet "class_": CLASSES["Barbarian"], # Based on the Human sheet
"alignment": "chaotic good", "alignment": "chaotic good",
"description": "A robust Human warrior, charging into battle.", "description": "A robust Human warrior, charging into battle.",
"stats": { "stats": {
@@ -67,8 +67,8 @@ pre_generated_characters = {
"name": "Drok", # Example Name "name": "Drok", # Example Name
"gender": "Other", "gender": "Other",
"age": 75, # Dwarves live longer "age": 75, # Dwarves live longer
"race": Dwarf, "race": RACES["Dwarf"],
"job": Cleric, # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus) "class_": CLASSES["Cleric"], # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus)
"alignment": "lawful good", "alignment": "lawful good",
"description": "A stout Hill Dwarf cleric, a pillar of his community.", "description": "A stout Hill Dwarf cleric, a pillar of his community.",
"stats": { "stats": {
@@ -142,10 +142,10 @@ def startGame():
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# --- Race and Job Selection --- # --- Race and Class Selection ---
race = None race = None
job = None class_ = None
# Loop until Race and Job are confirmed # Loop until Race and Class are confirmed
while True: while True:
# Race Selection # Race Selection
selected_race_obj = None selected_race_obj = None
@@ -154,11 +154,11 @@ def startGame():
race_choice = input("Please select a race to learn more about it:\n" race_choice = input("Please select a race to learn more about it:\n"
"1) Elf\n2) Dwarf\n3) Human\n >> ").title() "1) Elf\n2) Dwarf\n3) Human\n >> ").title()
if race_choice in ['Elf', '1']: if race_choice in ['Elf', '1']:
selected_race_obj = Elf selected_race_obj = RACES["Elf"]
elif race_choice in ['Dwarf', '2']: elif race_choice in ['Dwarf', '2']:
selected_race_obj = Dwarf selected_race_obj = RACES["Dwarf"]
elif race_choice in ['Human', '3']: elif race_choice in ['Human', '3']:
selected_race_obj = Human selected_race_obj = RACES["Human"]
else: else:
print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n") print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n")
continue continue
@@ -177,48 +177,48 @@ def startGame():
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# Job Selection # Class Selection
selected_job_obj = None selected_class_obj = None
# Loop for selecting and viewing job # Loop for selecting and viewing class
while True: 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() "1) Barbarian\n2) Cleric\n3) Wizard\n >> ").title()
if job_choice in ['Barbarian', '1']: if class_choice in ['Barbarian', '1']:
selected_job_obj = Barbarian selected_class_obj = CLASSES["Barbarian"]
elif job_choice in ['Cleric', '2']: elif class_choice in ['Cleric', '2']:
selected_job_obj = Cleric selected_class_obj = CLASSES["Cleric"]
elif job_choice in ['Wizard', '3']: elif class_choice in ['Wizard', '3']:
selected_job_obj = Wizard selected_class_obj = CLASSES["Wizard"]
else: 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 continue
if selected_job_obj: if selected_class_obj:
print(f"\n--- {selected_job_obj.name} ---") print(f"\n--- {selected_class_obj.name} ---")
print(selected_job_obj.__repr__()) print(selected_class_obj.__repr__())
print('Would you like to proceed with this job or view another?') print('Would you like to proceed with this class or view another?')
proceed = input('1) Proceed\n2) View another job\n >> ') proceed = input('1) Proceed\n2) View another class\n >> ')
if proceed.lower() in ('1', 'proceed'): if proceed.lower() in ('1', 'proceed'):
job = selected_job_obj # Assign job object class_ = selected_class_obj # Assign class object
break # Exit job selection loop break # Exit class selection loop
elif proceed.lower() in ('2', 'view', 'view another', 'view another job'): elif proceed.lower() in ('2', 'view', 'view another', 'view another class'):
continue # Restart job selection continue # Restart class selection
else: else:
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# Final confirmation for both Race and Job # Final confirmation for both Race and Class
while True: while True:
# This safeguard should ideally not be hit if inner loops work correctly # This safeguard should ideally not be hit if inner loops work correctly
if race is None or job is None: if race is None or class_ is None:
print("Error: Race or Job not selected. Restarting Race/Job selection.") print("Error: Race or Class not selected. Restarting Race/Class selection.")
break # Break out of this inner loop to restart the outer race/job loop 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} {job.name}.\nIs this correct? Y/N\n >> ") 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']: if correct.lower() in ['y', 'yes']:
return name, gender, age, race, job, None# All confirmed, return values return name, gender, age, race, class_, None# All confirmed, return values
elif correct.lower() in ['n', 'no']: 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 break
else: else:
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
@@ -233,19 +233,19 @@ def startGame():
gender = input("Gender (Male, Female, Other): ") gender = input("Gender (Male, Female, Other): ")
age = int(input("Age: ")) age = int(input("Age: "))
race_str = input("Race (Elf, Dwarf, Human): ") 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 # Map string inputs to actual Race/Class objects for consistency
race_map = {'elf': Elf, 'dwarf': Dwarf, 'human': Human} race_map = {'elf': RACES["Elf"], 'dwarf': RACES["Dwarf"], 'human': RACES["Human"]}
job_map = {'barbarian': Barbarian, 'cleric': Cleric, 'wizard': Wizard} class_map = {'barbarian': CLASSES["Barbarian"], 'cleric': CLASSES["Cleric"], 'wizard': CLASSES["Wizard"]}
actual_race = race_map.get(race_str.lower()) 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: if actual_race and actual_class:
return name, gender, age, actual_race, actual_job, None return name, gender, age, actual_race, actual_class, None
else: else:
print("Invalid race or job entered for pre-generated character. Please try again.") print("Invalid race or class entered for pre-generated character. Please try again.")
start = "1" start = "1"
continue continue
return None return None
@@ -267,7 +267,7 @@ def startGame():
print("Who would you like to to play?") print("Who would you like to to play?")
for key, char_data in pre_generated_characters.items(): for key, char_data in pre_generated_characters.items():
print( print(
f"{key}) {char_data['name']} ({char_data['race'].name_adjective} {char_data['job'].name}, {char_data['alignment']})") 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.") print("Type 'back' to return to character creation options.")
char_choice = input(">> ").strip().lower() char_choice = input(">> ").strip().lower()
@@ -284,7 +284,7 @@ def startGame():
print(f"Gender: {selected_char_data['gender']}") print(f"Gender: {selected_char_data['gender']}")
print(f"Age: {selected_char_data['age']}") print(f"Age: {selected_char_data['age']}")
print(f"Race: {selected_char_data['race'].name_adjective}") print(f"Race: {selected_char_data['race'].name_adjective}")
print(f"Class: {selected_char_data['job'].name}") print(f"Class: {selected_char_data['class_'].name}")
print(f"Alignment: {selected_char_data['alignment']}") print(f"Alignment: {selected_char_data['alignment']}")
print(f"Description: {selected_char_data['description']}") print(f"Description: {selected_char_data['description']}")
print(f"Age: {selected_char_data['age']}") print(f"Age: {selected_char_data['age']}")
@@ -340,11 +340,11 @@ def startGame():
gender = selected_char_data['gender'] gender = selected_char_data['gender']
age = selected_char_data['age'] age = selected_char_data['age']
race = selected_char_data['race'] race = selected_char_data['race']
job = selected_char_data['job'] class_ = selected_char_data['class_']
pre_allocated_stats = selected_char_data['stats'] pre_allocated_stats = selected_char_data['stats']
print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {job.name}.") print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {class_.name}.")
return name, gender, age, race, job, pre_allocated_stats # Exit all loops and function return name, gender, age, race, class_, pre_allocated_stats # Exit all loops and function
elif confirm_choice in ("2", "no"): elif confirm_choice in ("2", "no"):
print("\nReturning to pre-generated character selection.") print("\nReturning to pre-generated character selection.")
@@ -532,7 +532,13 @@ def get_instructions():
"Enter cave | house | room - maybe... TBC\n" "Enter cave | house | room - maybe... TBC\n"
"Scene or Location - Replays the current area's details\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" "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.") "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) print(descrip1)
time.sleep(3) # Reduced sleep for faster testing time.sleep(3) # Reduced sleep for faster testing
print(descrip2) print(descrip2)
@@ -580,6 +586,24 @@ def parse(input_text):
command = "go" command = "go"
object1 = " ".join(words[1:]) object1 = " ".join(words[1:])
return command, object1 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 # Single-word commands
if words[0] == "help": if words[0] == "help":
@@ -630,6 +654,14 @@ def parse(input_text):
command = "drop" # User needs to specify what to drop command = "drop" # User needs to specify what to drop
object1 = None 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": elif words[0] == "quit":
command = "quit" command = "quit"
else: else:

330
main.py
View File

@@ -1,4 +1,5 @@
import sys import sys
from random import randint
# Add the current directory to sys.path to allow imports from sibling files # Add the current directory to sys.path to allow imports from sibling files
sys.path.append('.') sys.path.append('.')
from player import Player from player import Player
@@ -6,6 +7,37 @@ from function_list import startGame, parse, error_message, get_instructions, wai
from scene import * 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: class Engine:
""" """
The main game engine class to manage game state, current scene, and player. The main game engine class to manage game state, current scene, and player.
@@ -36,8 +68,8 @@ class Engine:
# Display lootable containers # Display lootable containers
if self.current_scene.lootable_items: if self.current_scene.lootable_items:
print("\nYou also notice:") print("\nYou also notice:")
for container_name, items_in_container in self.current_scene.lootable_items.items(): for container_name, 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 if container.has_items():
print(f" - {container_name.title()}") print(f" - {container_name.title()}")
def look_around(self): def look_around(self):
@@ -53,8 +85,8 @@ class Engine:
# Display lootable containers # Display lootable containers
if self.current_scene.lootable_items: if self.current_scene.lootable_items:
print("\nYou also notice:") print("\nYou also notice:")
for container_name, items_in_container in self.current_scene.lootable_items.items(): for container_name, 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 if container.has_items():
print(f" - {container_name.title()}") print(f" - {container_name.title()}")
@@ -115,39 +147,80 @@ class Engine:
if not found_in_scene: if not found_in_scene:
# Check lootable containers in the current scene # Check lootable containers in the current scene
found_in_loot = False pairs = list(self.current_scene.lootable_items.items())
for container_name, items_in_container in self.current_scene.lootable_items.items(): match = disambiguate(object_name, pairs, key_fn=lambda kv: kv[0])
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
for item_obj, count in items_in_container.items(): if match:
if object_name.lower() in item_obj.name.lower() and count > 0: container_name, container = match
print(f"\n--- {item_obj.name} ---") self._examine_container(container_name, container)
print(item_obj.description) else:
if isinstance(item_obj, Weapon): # Check items inside containers
print(f"Damage: {item_obj.damage1H or item_obj.damage2H or item_obj.versatiledmg}") candidates = []
print(f"Damage Type: {item_obj.dmgType.name.title()}") for container_name, container in self.current_scene.lootable_items.items():
elif isinstance(item_obj, Armor): for item_obj, count in container.contents.items():
print(f"AC: {item_obj.ac}") if count > 0 and object_name.lower() in item_obj.name.lower():
print(f"Grade: {item_obj.grade}") candidates.append((container_name, container, item_obj))
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 not found_in_loot: seen = set()
print(f"You don't see or have '{object_name}' to examine.") 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): def take_item(self, item_name):
""" """
@@ -185,83 +258,94 @@ class Engine:
def loot_container(self, container_name): def loot_container(self, container_name):
"""Allows the player to loot items from a specified container.""" """Allows the player to loot items from a specified container."""
container_found = False pairs = list(self.current_scene.lootable_items.items())
for current_container_name, items_in_container in list(self.current_scene.lootable_items.items()): match = disambiguate(container_name, pairs, key_fn=lambda kv: kv[0])
if current_container_name.lower() == container_name.lower(): if not match:
container_found = True print(f"You don't see a '{container_name}' here to loot.")
if not items_in_container: return
print(f"The {container_name} is empty.")
return
print(f"You look inside the {container_name}. You see:") current_name, container = match
loot_list = list(items_in_container.items())
while True: if container.is_locked:
for i, (item_obj, count) in enumerate(loot_list, 1): print(f"The {current_name} is locked.")
print(f"{i}) {item_obj.name} (x{count})") return
if container.is_trapped and not container.trap_disarmed:
take_all_option = len(loot_list) + 1 if container.trap_detected:
leave_option = len(loot_list) + 2 print(f"The {current_name} is trapped. You'll need to disable the trap first.")
return
print(f"{take_all_option}) Take all") else:
print(f"{leave_option}) Leave") print(f"You open the {current_name}. A trap is triggered!")
# TODO: trap damage/effects
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(items_in_container.items()):
self.player.inventory.add_item(item_obj, count)
print(f"- Took {count} {item_obj.name}")
del items_in_container[item_obj]
print(f"The {container_name} is now empty.")
return
elif choice == "leave" or (choice.isdigit() and int(choice) == leave_option):
print(f"You leave the {container_name} untouched.")
return
else:
try:
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:
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)
items_in_container[item_obj] -= take_count
print(f"You took {take_count} {item_obj.name}.")
if items_in_container[item_obj] <= 0:
del items_in_container[item_obj]
loot_list = list(items_in_container.items())
if not items_in_container:
print(f"The {container_name} is now empty.")
return
elif not loot_list:
print(f"The {container_name} is now empty.")
return
else:
print("Invalid amount or not enough items.")
else:
print("Invalid selection. Please choose a valid item number, 'take all', or 'leave'.")
except ValueError:
print("Invalid selection. Please enter a number, 'take all', or 'leave'.")
return return
if not container_found: if not container.contents:
print(f"You don't see a '{container_name}' here to loot.") print(f"The {current_name} is empty.")
return
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:
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:
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 selection. Please choose a valid item number, 'take all', or 'leave'.")
except ValueError:
print("Invalid selection. Please enter a number, 'take all', or 'leave'.")
# Initialize the game engine # Initialize the game engine
@@ -272,10 +356,10 @@ def main_game_loop():
The main loop for the game, handling character creation and continuous gameplay. The main loop for the game, handling character creation and continuous gameplay.
""" """
# Character Creation # Character Creation
name, gender, age, race, job, pre_allocated_stats = startGame() name, gender, age, race, class_, pre_allocated_stats = startGame()
# Create player instance # 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 game_engine.set_player(player) # Set the player in the game engine
# Display initial character summary # Display initial character summary
@@ -286,13 +370,16 @@ def main_game_loop():
# For 'Pre-generated Character' # For 'Pre-generated Character'
game_engine.player.stats = pre_allocated_stats game_engine.player.stats = pre_allocated_stats
game_engine.player.getModifier() game_engine.player.getModifier()
game_engine.player.set_stats_job() # Still need to set HP based on job and Con modifier 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 game_engine.player.current_stats() # Display stats after setting them
print(' ') print(' ')
game_engine.player.inventory.current_equipment() game_engine.player.inventory.current_equipment()
print(' ') print(' ')
game_engine.player.inventory.current_inventory() game_engine.player.inventory.current_inventory()
print(' ') print(' ')
if game_engine.player.class_.is_caster:
game_engine.player.print_spellbook()
print(' ')
wait() wait()
print("\nYour adventure begins...") print("\nYour adventure begins...")
@@ -340,6 +427,29 @@ def main_game_loop():
elif command == "go": elif command == "go":
direction = obj1 direction = obj1
game_engine.move_to_scene(direction) 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": elif command == "quit":
print("Thanks for playing!") print("Thanks for playing!")
break break

28
pc/README.md Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

354
player.py
View File

@@ -1,16 +1,33 @@
import re
from random import randint from random import randint
from enum_list import DamageType, DamageMod, ItemType, Slots 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 # Define Item classes here, as they are fundamental building blocks
class Item: class Item:
"""The base class for all items""" """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.name = name
self.description = description self.description = description
self.value = value self.value = value
self.magical = magical self.magical = magical
self.itemType = itemType self.itemType = itemType
self.attunement = attunement self.attunement = attunement
self.weight = weight # in pounds, per SRD carrying capacity rules
def __repr__(self): def __repr__(self):
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n" return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n"
@@ -29,13 +46,14 @@ class Money(Item):
value=self.amt, value=self.amt,
magical=self.magical, magical=self.magical,
itemType=ItemType.Money, itemType=ItemType.Money,
attunement=False) attunement=False,
weight=0.02) # SRD: 50 coins weigh 1 lb, regardless of denomination
class Weapon(Item): class Weapon(Item):
"""The base class for all weapons""" """The base class for all weapons"""
def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg, light, finesse, def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg, light, finesse,
dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement): dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement, weight=0):
self.slot = slot self.slot = slot
self.damage1H = damage1H self.damage1H = damage1H
self.damage2H = damage2H self.damage2H = damage2H
@@ -47,7 +65,7 @@ class Weapon(Item):
self.versatile = versatile self.versatile = versatile
self.thrown = thrown self.thrown = thrown
self.itemType = ItemType self.itemType = ItemType
super().__init__(name, description, value, magical, ItemType, attunement) super().__init__(name, description, value, magical, ItemType, attunement, weight)
def __str__(self): def __str__(self):
if self.damage2H is None and self.damage1H is not None: if self.damage2H is None and self.damage1H is not None:
@@ -84,14 +102,15 @@ class Weapon(Item):
class Armor(Item): class Armor(Item):
"""The base class for all armor""" """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.slot = slot
self.grade = grade self.grade = grade
self.ac = ac self.ac = ac
self.stealthDis = disadvantage self.stealthDis = disadvantage
self.dmgRes = dmgRes self.dmgRes = dmgRes
self.itemType = ItemType self.itemType = ItemType
super().__init__(name, description, value, magical, ItemType, attunement) super().__init__(name, description, value, magical, ItemType, attunement, weight)
def __repr__(self): def __repr__(self):
if self.stealthDis: if self.stealthDis:
@@ -148,10 +167,11 @@ class Inventory:
def add_item(self, item, count=1, silent=False): def add_item(self, item, count=1, silent=False):
"""Adds an item to the inventory.""" """Adds an item to the inventory."""
if item in self.items: existing = next((e for e in self.items if e.name.lower() == item.name.lower()), None)
self.items[item]["Count"] += count if existing:
self.items[existing]["Count"] += count
if not silent: 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: else:
self.items[item] = {"Count": count, "object": item.itemType} self.items[item] = {"Count": count, "object": item.itemType}
if not silent: if not silent:
@@ -178,11 +198,11 @@ class Inventory:
print(f'You are currently carrying:') print(f'You are currently carrying:')
found_un_equipped = False found_un_equipped = False
for item_obj, item_data in self.items.items(): for item_obj, item_data in self.items.items():
# Check if the item is in the backpack AND not currently equipped in any slot equipped_count = sum(1 for eq in self.equipped_items.values() if eq is item_obj)
if item_obj not in self.equipped_items.values(): backpack_count = item_data["Count"] - equipped_count
if backpack_count > 0:
found_un_equipped = True found_un_equipped = True
count = item_data["Count"] print(f'{item_obj.name} x {backpack_count}\n'
print(f'{item_obj.name} x {count}\n'
f' {item_obj.description}\n') f' {item_obj.description}\n')
if not found_un_equipped: if not found_un_equipped:
print(" Your rucksack is empty.") print(" Your rucksack is empty.")
@@ -299,6 +319,65 @@ class Inventory:
attune_count += 1 attune_count += 1
self.equipped_items[Slots.Attunement] = attune_count 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) --- # --- Item Instances (Moved here for clarity, but still defined once) ---
# Different types of coins # Different types of coins
@@ -323,7 +402,8 @@ rock = Weapon(
thrown=True, thrown=True,
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=2
) )
dagger = Weapon( dagger = Weapon(
@@ -342,7 +422,8 @@ dagger = Weapon(
thrown=True, thrown=True,
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=1
) )
polearm = Weapon( polearm = Weapon(
@@ -361,7 +442,8 @@ polearm = Weapon(
thrown=False, # Polearms are not typically thrown thrown=False, # Polearms are not typically thrown
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=6
) )
tornRags = Armor( tornRags = Armor(
@@ -375,7 +457,8 @@ tornRags = Armor(
dmgRes="None", dmgRes="None",
magical=False, magical=False,
ItemType=ItemType.Armor, ItemType=ItemType.Armor,
attunement=False attunement=False,
weight=2
) )
paper = Item( paper = Item(
@@ -384,19 +467,22 @@ paper = Item(
value=None, value=None,
magical=False, magical=False,
itemType=ItemType.Item, itemType=ItemType.Item,
attunement=False attunement=False,
weight=0
) )
class Player: class Player:
"""Character Creation""" """Character Creation"""
def __init__(self, name, gender, age, race, job): def __init__(self, name, gender, age, race, class_):
# Identity # Identity
self.name = name self.name = name
self.gender = gender self.gender = gender
self.age = age self.age = age
self.race = race 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 # Defence
self.ac = 0 # This will be calculated based on equipped armor self.ac = 0 # This will be calculated based on equipped armor
@@ -423,6 +509,19 @@ class Player:
# Equipment and Inventory # Equipment and Inventory
self.inventory = Inventory() # Each player gets their own inventory instance 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 # Ability Scores
self.stats = {"Strength": 0, self.stats = {"Strength": 0,
"Dexterity": 0, "Dexterity": 0,
@@ -540,22 +639,198 @@ class Player:
print("As a Dwarf, your constitution increases by 2.") print("As a Dwarf, your constitution increases by 2.")
self.stats["Constitution"] += 2 self.stats["Constitution"] += 2
def set_stats_job(self): def set_stats_class(self):
"""Applies job-specific stats like hit points.""" """Applies class-specific stats like hit points."""
# Using self.job.name to access the job name from the Job object # Using self.class_.name to access the class name from the CharacterClass object
if self.job.name.lower() == "barbarian": if self.class_.name.lower() == "barbarian":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) # Use job's hitdie range self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) # Use class's hitdie range
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP
self.currentHP = self.maxHP self.currentHP = self.maxHP
elif self.job.name.lower() == "cleric": elif self.class_.name.lower() == "cleric":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
self.currentHP = self.maxHP self.currentHP = self.maxHP
elif self.job.name.lower() == "wizard": elif self.class_.name.lower() == "wizard":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
self.currentHP = self.maxHP 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): def current_stats(self):
"""Prints a display of the user's current statistics.""" """Prints a display of the user's current statistics."""
print(f'\nYour current stats are:') print(f'\nYour current stats are:')
@@ -584,18 +859,29 @@ class Player:
self.allocation() # 1. Sets base self.stats from rolls. self.allocation() # 1. Sets base self.stats from rolls.
self.set_stats_race() # 2. Applies racial bonuses to self.stats. 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.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. self.current_stats() # 5. Displays the final stats.
print(' ') print(' ')
self.inventory.current_equipment() # Corrected to call instance method self.inventory.current_equipment() # Corrected to call instance method
print(' ') print(' ')
self.inventory.current_inventory() self.inventory.current_inventory()
print(' ') print(' ')
if self.class_.is_caster:
self.print_spellbook()
print(' ')
def health_check(self): def health_check(self):
"""Print out current/max HP""" """Print out current/max HP"""
print(f'You have {self.currentHP}/{self.maxHP} 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): def take_damage(self, DMGtype, size):
"""Define the damage type and dice size""" """Define the damage type and dice size"""
damage = randint(1, size) damage = randint(1, size)

View File

@@ -39,7 +39,7 @@ class Elf(Race):
speed = { speed = {
'Walking': 30 'Walking': 30
} }
darkvision = [30, 60] darkvision = 60
language = ["Common", "Elvish"] language = ["Common", "Elvish"]
advantage = [] advantage = []
resistance = [] resistance = []
@@ -155,6 +155,8 @@ class Human(Race):
language, advantage, resistance, proficiency, traits) language, advantage, resistance, proficiency, traits)
Elf = Elf() RACES = {
Dwarf = Dwarf() "Elf": Elf(),
Human = Human() "Dwarf": Dwarf(),
"Human": Human(),
}

View File

@@ -5,6 +5,36 @@ sys.path.append('.')
from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1 from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1
from enum_list import * 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: class Scene:
""" """
Represents a single location or area in the game. Represents a single location or area in the game.
@@ -45,15 +75,12 @@ class Scene:
return self.exits.get(direction.lower()) return self.exits.get(direction.lower())
def add_lootable_item(self, container_name, item_obj, count=1): 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: if container_name not in self.lootable_items:
self.lootable_items[container_name] = {} self.lootable_items[container_name] = Container(container_name, "")
self.lootable_items[container_name][item_obj] = self.lootable_items[container_name].get(item_obj, 0) + count 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): def add_available_item(self, item_obj, count=1):
""" """
@@ -75,18 +102,31 @@ class Scene:
del self.available_items[item_obj] del self.available_items[item_obj]
def remove_lootable_item(self, container_name, item_obj, count=1): 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. base_name = f"{enemy_name.lower()} corpse"
:param container_name: The name of the container. name = base_name
:param item_obj: The Item object to remove. counter = 1
:param count: The quantity to remove. while name in self.lootable_items:
""" counter += 1
if container_name in self.lootable_items and item_obj in self.lootable_items[container_name]: name = f"{base_name} {counter}"
self.lootable_items[container_name][item_obj] -= count
if self.lootable_items[container_name][item_obj] <= 0: corpse = Container(name, f"The lifeless body of a {enemy_name}.")
del self.lootable_items[container_name][item_obj] if isinstance(items, dict):
if not self.lootable_items[container_name]: # If container is empty, remove it for item_obj, count in items.items():
del self.lootable_items[container_name] 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 --- # --- Default Scenes ---
@@ -112,9 +152,9 @@ tutorial = Scene(
) )
tutorial.add_available_item(rock, 1) tutorial.add_available_item(rock, 1)
tutorial.add_available_item(paper, 2) tutorial.add_available_item(paper, 2)
tutorial.add_lootable_item("suspicious tree trunk", Item("Test Item", tutorial_trunk = Container("suspicious tree trunk", "A hollow tree trunk with a small gap in the bark.")
"This is for testing purposes", None, tutorial_trunk.add_item(Item("Test Item", "This is for testing purposes", None, False, ItemType.Item, False), 1)
False, ItemType.Item, False), 1) tutorial.add_container(tutorial_trunk)
# Scene 1: Starting Clearing # Scene 1: Starting Clearing
starting_clearing = Scene( starting_clearing = Scene(
@@ -125,8 +165,10 @@ starting_clearing = Scene(
"You notice a small, weathered wooden chest near a fallen log." "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_available_item(rock, 3) # Rocks lying on the ground
starting_clearing.add_lootable_item("small wooden chest", GP1, 15) starting_clearing_chest = Container("small wooden chest", "A small, weathered wooden chest with rusty iron bands.")
starting_clearing.add_lootable_item("small wooden chest", SP1, 20) 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 # Scene 2: Forest Path

221
spell_list.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0

89
web/server.py Normal file
View 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)

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

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
View 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
View 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
View 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))
);
});