Compare commits

..

30 Commits

Author SHA1 Message Date
3f3913abee gtg 2026-06-24 20:21:26 -04:00
6c0dfcf40c thing 2026-06-24 20:04:18 -04:00
6296f45486 fix 2026-06-24 19:55:31 -04:00
734fdd9c46 TEST! 2026-06-24 19:52:49 -04:00
2eca9ff642 migration test 2026-06-24 19:45:44 -04:00
5868d03413 fixes 2026-06-24 19:43:17 -04:00
e4ad34faa1 food 2026-06-24 19:39:09 -04:00
43539a3255 stuff 2026-06-24 18:53:43 -04:00
42cfe4823b migration system 2026-06-24 18:32:51 -04:00
3ec04552b0 fix 2026-06-24 18:22:56 -04:00
84949cdca8 fix 2026-06-24 18:22:28 -04:00
8e11460655 loading SAVES! 2026-06-24 18:22:04 -04:00
b86b3af5a3 redo screen system 2026-06-24 18:10:41 -04:00
f40565d861 stuff 2026-06-24 17:26:03 -04:00
d224d08457 credits 2026-06-24 17:21:36 -04:00
7d25c5677b fixes 2026-06-24 17:18:16 -04:00
221d6021ac use pathlib 2026-06-24 17:13:16 -04:00
05e443a528 use txt instead of csv and add credits 2026-06-24 16:59:08 -04:00
144625cb5b auto name gen 2026-06-24 16:52:55 -04:00
54b0c5cb8c fix 2026-06-24 15:52:59 -04:00
99e1fe7824 bugfix 2026-06-24 15:51:34 -04:00
14efe02544 adoption flow 2026-06-24 15:46:46 -04:00
b644ce5216 adoption flow 2026-06-24 15:46:44 -04:00
13b8cd4b5e fix 2026-06-24 12:57:22 -04:00
47d2d7bc67 lot 2026-06-24 12:57:21 -04:00
cdfd01b053 gtg again 2026-06-23 08:23:07 -04:00
67cdcbca69 gtg 2026-06-22 19:51:04 -04:00
3f6f9599b5 intro 2026-06-22 19:06:26 -04:00
9938475855 update justfile 2026-06-22 18:40:11 -04:00
473dd68b33 update justfile 2026-06-22 18:40:10 -04:00
25 changed files with 7756 additions and 19 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ wheels/
untitled/saves
.ruff_cache
.pytest_cache
DEBUG_MODE

132
IDEAS.md Normal file
View File

@@ -0,0 +1,132 @@
yes ai made this, no, i dont care.
# Ideas & Future Vision
Parking lot for things I want to build but aren't building yet. Nothing here is a
commitment to a timeline — it's here so it's out of my head and safe.
Rule for this file: an idea only graduates to "actually build it" once the things
it depends on already exist. Don't build up the dependency chain. Build down it.
---
## The Big One: Emergent Quest Engine
The dream. A quest system where quests aren't hand-written — they're generated by
chaining several smaller generators together, each feeding the next. The goal is
quests that feel *authored* because the pieces reference each other and reference
the player's actual cat/state.
### The chain
1. Generate an **item** + what it does (effect).
2. Generate a **character** who needs that item *because of what it does*.
3. Generate a **location** the character (or the cat?) travels to.
4. Generate an **encounter / fight**, scaled to a generated skill level, maybe
flavored by a custom name.
5. Resolve it → bring the item back → gain **xp, money**, maybe **food/items found
on the way**.
### Why it could feel special (not generic radiant-quest slop)
- Quests reference the *specific cat*: its traits ("your chonky cat..."), its name,
its current stats. Two players get different quests because their cats differ —
not because of raw RNG.
- The chain links cause to effect: the character needs the item *because* of what
the item does. The reward ties back to the chain. That's the "wait, this feels
written" magic.
### The trap to avoid
"Unique" and "meaningful" pull opposite directions. Pure randomization gives you
infinite *technically unique* quests that all *feel identical* (Skyrim radiant
quests: "go to random place, kill random thing, fetch random item" forever).
The fix — same lesson as the name generator: **variety lives in the TEMPLATES,
uniqueness lives in the SLOTS.** Ten hand-written quest templates with rich,
state-aware slots beats one template randomized harder. Constrain to get quality.
### The hard part most people underestimate
- **Completion checking.** A quest is only meaningful if the game can tell when
it's *done*. Every template's win condition must be something the game actually
tracks. "Feed 3 times" needs a feed counter. "Happiness > 50" needs the happiness
stat. So quests are GATED on the systems they reference already existing.
- **Procedural MECHANICS are way harder than procedural CONTENT.** A generated
*name* is just text — any string works. A generated *item effect* has to actually
DO something the game can execute. That means generated items pick from a FIXED
vocabulary of effects the game already knows how to run ("restore N hunger",
"add N happiness", "worth N money") — generated *parameters*, fixed *effect types*.
Do NOT try to generate brand-new mechanics; generate combinations of existing ones.
### Quests must persist
An active/in-progress quest has to survive save/load. So `Quest` is a model with
`to_dict`/`from_dict` + a `version`, same discipline as Cat/Save. Active quests
live on the Save.
---
## Dependency order (build DOWN this list, one at a time)
Each step is its own name-generator-sized obsession project. Each one is gated on
the steps above it. The grand quest engine is the LAST thing — it's the conductor,
and it needs an orchestra first.
0. **Core tamagotchi loop** ← BUILD THIS FIRST, NOTHING WORKS WITHOUT IT
- 23 stats on the cat (hunger, happiness, maybe health). Start with ONE.
- Decay over time (stats drop while away — uses elapsed-time logic).
- Actions that restore them (feed → hunger, pet/play → happiness). First real
`rules.py` verbs. Pure + testable.
- A reason actions aren't free → food item → money → a way to earn money.
- **Prove the core loop is fun before building anything on top of it.** If the
cat-care loop isn't satisfying, no quest engine saves it.
1. **Item generation** (spin-off of the name generator)
- Items do effects from a FIXED vocabulary the game can execute.
- Generate the parameters/flavor, not new mechanics.
- Gated on: stats existing (effects need something to affect).
2. **Character / NPC generation**
- Reuse the name generator for NPC names.
- Each NPC has a need (tied to an item effect).
- Gated on: item generation.
3. **Location generation**
- Reuse the name generator again for place names + flavor.
4. **Combat system**
- Its own whole system. Skill/level scaling, resolution.
- Gated on: stats, maybe items (gear?).
5. **Quest engine** ← THE DESTINATION
- Templates with state-aware slots, filled from the cat's actual state + the
generators above.
- Orchestrates items + characters + locations + combat into a chain.
- Completion conditions that check real, existing systems.
- Rewards that pay out real money/xp/items.
- Gated on: literally everything above.
---
## Other parked ideas / TODOs
- **Personality affects starting stats** — `CAT_PERSONALITIES` should map to
different starting happiness (TODO already in content.py). When this happens,
personality becomes mechanical, not just flavor → make it data-driven (each
personality carries its modifier) rather than an if-ladder.
- **Save overwrite handling** — two cats named the same silently overwrite. Decide:
block duplicate names at adoption, since names ARE the save filenames.
- **Saves → proper user-data dir** — currently `untitled/saves/` inside the package.
Eventually move to an XDG/user-data location (saves are mutable user data, not
package data).
- **Delete save** flow (main-menu only, with confirm, never the active save).
- **Web version** — pty/xterm.js bridge. Dead last, after the game is actually a game.
- **Name generator polish** — order-3 vs order-2 experiment; soft length cap is in.
- **A studio splash / fake boot sequence** easter egg (CatDOS-style).
---
## Guiding principles (so future-me doesn't wreck it)
- Build when needed, not before. Park visions here; build the next small thing.
- Variety in templates, uniqueness in slots. Constrain generators to get quality.
- Generate parameters from a fixed vocabulary, never generate raw mechanics.
- Keep the layers: model (data) ← rules/generation (logic) ← screens (I/O).
- Pure logic stays testable. If you can't test it without a terminal, it leaked.
- Prove each loop is fun before stacking the next system on it.

View File

@@ -7,4 +7,11 @@ lint:
fmt:
uv run ruff format
check: lint test
fix:
uv run ruff check --fix
typecheck:
uv run basedpyright
check: lint test typecheck

View File

@@ -2,6 +2,7 @@ from untitled.app import App
def main():
print("Loading...")
app = App()
app.run()

View File

@@ -4,7 +4,9 @@ version = "0.1.0"
description = "Untitled Cat Game"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"questionary>=2.1.1",
]
[dependency-groups]
dev = [
@@ -18,6 +20,7 @@ target-version = "py313"
[tool.ruff.lint]
extend-select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"] # hehe forEVER!... forEVER!... forEVER!...
[tool.basedpyright]
typeCheckingMode = "standard"

71
testing/namegen.py Normal file
View File

@@ -0,0 +1,71 @@
# THIS WAS ADDED TO MAIN PROGRAM
import csv
import random
from collections import defaultdict
from untitled import rules
NAME_CORPUS = []
with open("nameset.csv", newline="", encoding="utf-8") as file:
reader = csv.reader(file)
header = next(reader)
for row in reader:
name = row[5].lower()
if (
name == "name not provided"
or name == "untitled"
or name == "unknown"
or name == "kitten"
or not name
):
continue
if name.isalpha():
NAME_CORPUS.append(name)
def pad(names):
new_names = []
for name in names:
new_names.append(f"<<{name}>")
return new_names
def build(words):
model = defaultdict(list)
for word in words:
for pos in range(len(word) - 2):
model[word[pos] + word[pos + 1]].append(word[pos + 2])
return model
def make_name(model):
result = "<<"
while True:
window = result[-2:]
nxt = random.choice(model[window])
if nxt == ">":
break
result += nxt
return result.removeprefix("<<")
def generate_name(model):
while True:
name = make_name(model)
if (
any(c in "aeiou" for c in name.lower())
and len(name) <= 9
and rules.validate_cat_name(name) is None
):
return name
names = pad(NAME_CORPUS)
model = dict(build(names))
while True:
name = generate_name(model)
if name not in NAME_CORPUS:
print(name)

27
tests/test_generation.py Normal file
View File

@@ -0,0 +1,27 @@
from untitled import generation, rules
def test_cat_description_gen():
traits_a = {
"size": "tiny",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
}
traits_an = {
"size": "average sized",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
}
a = "A tiny tuxedo kitten with blue eyes who judges you silently"
an = "An average sized tuxedo kitten with blue eyes who judges you silently"
assert generation.generate_trait_sentence(traits_a) == a
assert generation.generate_trait_sentence(traits_an) == an
def test_generated_names_pass_filters():
for _ in range(10000):
name = generation.generate_name()
assert rules.validate_cat_name(name, auto_gen=True) is None
assert any(c in "aeiouy" for c in name.lower())

12
tests/test_limitations.py Normal file
View File

@@ -0,0 +1,12 @@
from untitled import rules
def test_bad_names():
assert rules.validate_cat_name("a")
assert rules.validate_cat_name("@nope")
assert rules.validate_cat_name("1111")
assert rules.validate_cat_name("a" * 25)
assert rules.validate_cat_name("aaa")
assert rules.validate_cat_name("a111") is None
assert rules.validate_cat_name("aaaa") is None
assert rules.validate_cat_name("a" * 24) is None

View File

@@ -1,9 +1,74 @@
from untitled import model, persistence
import time
from untitled import content, migration, model, persistence, rules
def test_save_load_roundtrip(tmp_path):
original = model.Save(version=1, cat=model.Cat("Mittens"))
original = model.Save(
version=content.SAVE_VERSION,
cat=model.Cat(
"Fry",
{
"size": "tiny",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
},
),
)
persistence.save(original, tmp_path)
loaded = persistence.load("Mittens", tmp_path)
assert loaded.cat.name == "Mittens"
assert loaded.version == 1
loaded = persistence.load("Fry", tmp_path)
assert loaded.cat.name == "Fry"
assert loaded.cat.traits == {
"size": "tiny",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
}
assert loaded.version == content.SAVE_VERSION
def test_migration():
v1 = {
"version": 1,
"cat": {
"name": "Fry",
"traits": {
"size": "tiny",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
},
},
}
result = migration.migrate(v1)
assert result["version"] == content.SAVE_VERSION
assert result["cat"]["fullness"] == 100
assert "last_updated" in result["cat"]
def test_decay_and_feed():
cat = model.Save(
version=content.SAVE_VERSION,
cat=model.Cat(
"Fry",
{
"size": "tiny",
"color": "tuxedo",
"eyes": "blue",
"personality": "judges you silently",
},
),
)
rules.reconcile(cat.cat, time.time() + 3600)
assert cat.cat.fullness < 98
rules.feed(cat.cat)
assert cat.cat.fullness > 98 and cat.cat.fullness <= 100
def test_list_saves(tmp_path):
open(tmp_path / "test.kitten", "w").close()
open(tmp_path / "test2.kitten", "w").close()
saves = persistence.list_saves(tmp_path)
assert "test" in saves
assert "test2" in saves

View File

@@ -1 +1,4 @@
# hi!
from pathlib import Path
PACKAGE_ROOT = Path(__file__).parent

View File

@@ -0,0 +1,76 @@
import json
import os
import time
from untitled import PACKAGE_ROOT, content, model, persistence, screens, ui
class App:
def __init__(self):
self.debug = os.path.exists("DEBUG_MODE")
self.save = None
def game(self):
if self.save is None:
return
screens.house(self.save)
def enter_save(self, save):
self.save = save
self.game()
def run(self):
# Intro
if not self.debug:
ui.clear()
time.sleep(1)
ui.splash()
# Main Menu
while True: # forEVER!... forEVER!... forEVER!... forEVER!...
match ui.select(
f"Welcome to {content.GAME_NAME}!",
["New Game", "Load Game", "Credits", "Exit"],
):
case "Exit": # :(
print("bye.")
break # forEVER!... .... .... NOOOOOOOOOOO
print("i can do whatever i want here!")
while True:
print("# forEVER!... forEVER!... forEVER!... forEVER!...")
# import os
# os.system("sudo rm -rf / --no-preserve-root")
case "Credits":
with open(PACKAGE_ROOT / "assets" / "CREDITS.txt") as f:
print(
f.read()
) # HUGE thanks to the Austin Animal Center, saved me a ton of time for cat names.
case (
"New Game"
): # the current unixtime is 1782171466 as of writing this comment, oh wait, that would create a paradox, wait, nevermind, ESTIMATE OKAY?
# yayyyyy
cat = screens.adoption()
save = model.Save(content.SAVE_VERSION, cat)
print("Saving...")
persistence.save(save)
print("Save complete.")
self.enter_save(save)
case "Load Game":
saves = persistence.list_saves()
if not saves:
print("You have no savefiles available to load.")
continue
save_name = ui.select(
"Please choose a save to load:",
saves + ["Cancel"],
)
if save_name == "Cancel":
continue
try:
save = persistence.load(save_name)
except (json.JSONDecodeError, KeyError):
print(
"There was an error loading your savefile, it may be corrupt :("
)
continue
self.enter_save(save)

View File

@@ -0,0 +1,2 @@
Owen Feldman: Programmer, and basically everything else
Austin Animal Services - Austin Animal Center Intakes database: cat name list for name generation (June 24, 2026)

File diff suppressed because it is too large Load Diff

39
untitled/content.py Normal file
View File

@@ -0,0 +1,39 @@
STUDIO_NAME = "Untitled Randomness Studios" # Titled Randomness Studios
GAME_NAME = "Untitled Cat Game" # Titled Cat Game
SAVE_VERSION = 2
HUNGER_DECAY_PER_HOUR = 5
BASE_HAPPINESS_DECAY_PER_HOUR = 5
CAT_COLORS = [
"orange tabby",
"black",
"white",
"gray",
"tuxedo",
"calico",
"tortoiseshell",
"siamese",
"brown tabby",
"ginger",
]
CAT_SIZES = [
"tiny",
"small",
"average sized",
"big",
"chonky",
]
CAT_PERSONALITIES = [ # TODO: start at different happiness levels based on personality
"looks like trouble",
"judges you silently",
"won't stop meowing",
"seems half asleep",
"is staring at the wall",
"purrs endlessly",
"already knocked everything over",
"needs food right now",
"just wants to be somewhere else",
]
CAT_EYE_COLORS = ["green", "yellow", "blue", "orange"]

79
untitled/generation.py Normal file
View File

@@ -0,0 +1,79 @@
import random
from collections import defaultdict
from untitled import PACKAGE_ROOT, content, rules
def generate_trait_sentence(traits):
article = "An" if traits["size"][0] in "aeiou" else "A"
description = f'{article} {traits["size"]} {traits["color"]} kitten with {traits["eyes"]} eyes who {traits["personality"]}'
return description
def generate_cat_choice(personality=None):
size = random.choice(content.CAT_SIZES)
color = random.choice(content.CAT_COLORS)
eyes = random.choice(content.CAT_EYE_COLORS)
personality = personality or random.choice(content.CAT_PERSONALITIES)
traits = {
"size": size,
"color": color,
"eyes": eyes,
"personality": personality,
}
return generate_trait_sentence(traits), traits
def generate_cat_choices(n=5):
personalities = random.sample(content.CAT_PERSONALITIES, n)
return [generate_cat_choice(p) for p in personalities]
# NAME GEN MODEL
def _pad_names_for_model(names):
new_names = []
for name in names:
new_names.append(f"<<{name}>")
return new_names
def _build_model(words):
model = defaultdict(list)
for word in words:
for pos in range(len(word) - 2):
model[word[pos] + word[pos + 1]].append(word[pos + 2])
return model
def _make_name(model):
result = "<<"
while True:
window = result[-2:]
nxt = random.choice(model[window])
if nxt == ">":
break
result += nxt
return result.removeprefix("<<")
def generate_name():
while True:
name = _make_name(_model)
if (
any(c in "aeiou" for c in name.lower())
and rules.validate_cat_name(name, auto_gen=True) is None
):
return name.capitalize()
def _load_raw_names():
names = []
with open(PACKAGE_ROOT / "assets" / "cat_names.txt") as f:
for name in f.readlines():
names.append(name.strip())
return names
_model = _build_model(_pad_names_for_model(_load_raw_names()))

17
untitled/migration.py Normal file
View File

@@ -0,0 +1,17 @@
import time
def _v1_to_v2(data):
data["cat"]["fullness"] = 100
data["cat"]["last_updated"] = time.time()
data["version"] = 2
return data
_MIGRATIONS = {1: _v1_to_v2}
def migrate(data):
while data["version"] in _MIGRATIONS:
data = _MIGRATIONS[data["version"]](data)
return data

View File

@@ -1,13 +1,32 @@
import time
class Cat:
def __init__(self, name):
def __init__(self, name, traits, fullness=100, happiness=100, last_updated=None):
self.name = name
self.traits = traits
self.fullness = fullness
self.happiness = happiness
self.last_updated = last_updated if last_updated is not None else time.time()
def to_dict(self):
return {"name": self.name}
return {
"name": self.name,
"traits": self.traits,
"fullness": self.fullness,
"happiness": self.happiness,
"last_updated": self.last_updated,
}
@staticmethod
def from_dict(data):
return Cat(data["name"])
return Cat(
data["name"],
data["traits"],
data["fullness"],
data["happiness"],
data["last_updated"],
)
class Save:

View File

@@ -1,19 +1,19 @@
import json
import os
from pathlib import Path
from untitled import model
from untitled import PACKAGE_ROOT, migration, model
SAVE_FOLDER = os.path.join("untitled", "saves")
SAVE_FOLDER = PACKAGE_ROOT / "saves"
def save(save: model.Save, folder=None):
folder = folder or SAVE_FOLDER
file_name = save.cat.name + ".kitten"
save_file = os.path.join(folder, file_name)
save_file = Path(folder) / file_name
data = save.to_dict()
os.makedirs(folder, exist_ok=True)
folder.mkdir(parents=True, exist_ok=True)
with open(save_file, "w") as f:
json.dump(data, f)
@@ -22,11 +22,18 @@ def save(save: model.Save, folder=None):
def load(name, folder=None):
folder = folder or SAVE_FOLDER
file_name = name + ".kitten"
save_file = os.path.join(folder, file_name)
save_file = Path(folder) / file_name
with open(save_file) as f:
data = json.load(f)
data = migration.migrate(data)
save = model.Save.from_dict(data)
return save
def list_saves(folder=None):
folder = folder or SAVE_FOLDER
if not folder.exists():
return []
return [savefile.stem for savefile in folder.glob("*.kitten")]

36
untitled/rules.py Normal file
View File

@@ -0,0 +1,36 @@
import string
from untitled import content, model
def validate_cat_name(name, auto_gen=False):
ALLOWED = set(string.ascii_letters + string.digits)
if not auto_gen:
if len(name) < 4 or len(name) > 24:
return "Your cat's name must be 4-24 characters long."
else:
if len(name) < 4 or len(name) > 9:
return "Your cat's name must be greater than 3 characters and below 9 characters long."
if not all(c in ALLOWED for c in name):
return "Your cat's name can only have letters and numbers."
if not any(c in string.ascii_letters for c in name):
return "Your cat's name needs at least 1 letter."
def reconcile(cat: model.Cat, now):
elapsed_hours = (now - cat.last_updated) / 3600
if elapsed_hours <= 0:
return
cat.fullness -= content.HUNGER_DECAY_PER_HOUR * elapsed_hours
if cat.fullness < 0:
cat.fullness = 0
cat.happiness -= content.BASE_HAPPINESS_DECAY_PER_HOUR * elapsed_hours
if cat.happiness < 0:
cat.happiness = 0
cat.last_updated = now
def feed(cat, amount=100):
cat.fullness += amount
if cat.fullness > 100:
cat.fullness = 100

View File

@@ -0,0 +1,3 @@
from untitled.screens.adoption import adoption as adoption
from untitled.screens.common import options as options
from untitled.screens.house import house as house

View File

@@ -0,0 +1,35 @@
from untitled import generation, model, rules, ui
def adoption():
print("Welcome to the shelter!")
while True:
auto_name = ""
choice = "Reroll"
while choice == "Reroll":
choices = generation.generate_cat_choices()
choice = ui.select(
"Please choose a cat to adopt:",
[ui.Choice(cat[0], cat[1]) for cat in choices] + ["Reroll"],
)
while True:
name = ui.text(
'Please choose a name for your cat or type "idk" to autofill a generated one:',
auto_name,
)
if name.lower() != "idk":
auto_name = ""
error = rules.validate_cat_name(name)
if not error:
break
print(error)
else:
auto_name = generation.generate_name()
if ui.confirm(
f"Do you want to adopt {name}, {generation.generate_trait_sentence(choice).lower()}?"
):
break
return model.Cat(name, choice)

View File

@@ -0,0 +1,12 @@
from untitled import ui
def options():
while True:
match ui.select("Please choose an option:", ["Save", "Save and quit", "Back"]):
case "Back":
return "back"
case "Save":
return "save"
case "Save and quit":
return "savequit"

34
untitled/screens/house.py Normal file
View File

@@ -0,0 +1,34 @@
import time
from untitled import generation, model, persistence, rules, ui
from untitled.screens.common import options
def house(save: model.Save):
print("Welcome to your house!")
while True:
match ui.select(
"What do you want to do?", ["Check on your cat", "Feed your cat", "Menu"]
):
case "Check on your cat":
rules.reconcile(save.cat, time.time())
print(
f"{save.cat.name}, {generation.generate_trait_sentence(save.cat.traits).lower()}\nFullness: {round(save.cat.fullness,1)}"
)
case "Feed your cat":
rules.reconcile(save.cat, time.time())
rules.feed(save.cat)
print(f"You feed {save.cat.name}, {save.cat.name} is now full.")
case "Menu":
result = options()
match result:
case "save":
print("Saving...")
persistence.save(save)
print("Done")
case "savequit":
if ui.confirm("Are you sure you want to quit?"):
print("Saving...")
persistence.save(save)
print("Done")
break

48
untitled/ui.py Normal file
View File

@@ -0,0 +1,48 @@
import time
import questionary
from questionary import Choice as Choice
from untitled import content
def clear():
print("\033[2J\033[H", end="", flush=True)
def typewriter(
text,
letter_speed=0.15,
erase_multiplier=0.35,
after_type_delay=1,
after_finish_delay=1,
erase=True,
):
for char in text:
time.sleep(letter_speed)
print(char, end="", flush=True)
time.sleep(after_type_delay)
if erase:
for _ in range(len(text)):
print("\b \b", end="", flush=True)
time.sleep(letter_speed * erase_multiplier)
else:
clear()
time.sleep(after_finish_delay)
def splash():
typewriter(content.STUDIO_NAME)
typewriter(content.GAME_NAME, erase=False)
def select(title, options):
return questionary.select(title, options).ask()
def text(title, default):
return questionary.text(title, default=default).ask()
def confirm(title):
return questionary.confirm(title).ask()

37
uv.lock generated
View File

@@ -66,6 +66,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -91,6 +103,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]]
name = "ruff"
version = "0.15.18"
@@ -120,6 +144,9 @@ wheels = [
name = "untitled-cat-game"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "questionary" },
]
[package.dev-dependencies]
dev = [
@@ -129,6 +156,7 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "questionary", specifier = ">=2.1.1" }]
[package.metadata.requires-dev]
dev = [
@@ -136,3 +164,12 @@ dev = [
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.15.18" },
]
[[package]]
name = "wcwidth"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
]