# ============================================================================= # Fishing Mini-Project - Main Game File (OUTLINE ONLY) # ============================================================================= # This file is a STRUCTURAL OUTLINE: function signatures + descriptive comments. # The actual implementation will be written by the project author (human). # # Engine: cmu_graphics | Pattern: MVC (Model-View-Controller) # Data: creatures.tsv (tab-separated database at repo root) # Save: save.json (persistent progress in the script directory) # ============================================================================= from cmu_graphics import * import json import random import math import os #debug vars to store mouse pos gmouseX = 0 gmouseY = 0 # ============================================================================= # SECTION 1: DATA LOADING / FILE I/O # ----------------------------------------------------------------------------- # Helpers that read from and write to disk. Kept separate from game logic so # the data source (TSV path, save path) can change without touching gameplay. # ============================================================================= def loadCreatures(path="creatures.tsv"): # Read creatures.tsv (tab-separated) and parse it into a usable structure. # Suggested return: a list of dicts, one per creature, with keys matching # the header row: name, description, price, sprite-file, difficulty, # length, weight. Convert numeric columns (price, difficulty, length, # weight) from strings to int/float here; leave missing fields as sensible # defaults so partially-filled rows (like the current TSV) don't crash. creatures = [] with open(path) as f: lines = f.readlines() for line in lines[1:]: #ignore header data = line.split("\t") creatures.append({ "name": data[0], "description": data[1], "price": float(data[2]), "sprite-file": data[3], "difficulty": int(data[4]), "length": float(data[5]), #inches "weight": float(data[6]) #pounds }) return creatures def loadSave(path="save.json"): # Load save.json and return the player's saved state (money, owned baits, # owned upgrades, current bait, caught log, etc.). If the file is missing # or unreadable, return a fresh default-state dict so a first-time player # starts cleanly. This is the only place that reads the save file. defaultState = { "cash": 0, "owned_baits": ["Worms"], "owned_upgrades": [], "current_bait": "Worms", "catch_log": [] } try: with open(path, "r") as f: return json.load(f) except (FileNotFoundError, IOError): return defaultState def writeSave(saveState, path="save.json"): # Serialize the current player state to save.json. Call this at key moments # (after a sale, after a purchase, on quit) so progress persists between # sessions. Keep the written structure identical to what loadSave expects. with open(path, "w") as f: #overwrite existing f.write(json.dumps(saveState)) # ============================================================================= # SECTION 2: MODEL - STATE SETUP & GAME DATA # ----------------------------------------------------------------------------- # Everything that initializes and holds game state on the cmu_graphics `app`. # No drawing and no event handling here - just state. # ============================================================================= AVAILABLE_UPGRADES = [ # (name, bonus_type, bonus_modifier, price) #power mods - lowers speed of cursor (additive) ("Smart Reel", "power", 2, 100), ("Clever Reel", "power", 7, 350), ("Intelligent Reel", "power", 10, 1500), ("Genius Reel", "power", 20, 20000), #timing mods - lengthens bars in skill check (multiplicative) ("Medium Bobber", "timing", 1.1, 100), ("Large Bobber", "timing", 1.2, 300), ("Massive Bobber", "timing", 1.5, 1600), ("Enourmous Bobber", "timing", 2, 25000), #count mods - lowers number of bars in skill check (subtractive (capped at 1)) ("Shiny Rod", "count", 1, 450), ("Spectral Rod", "count", 1, 1200), ("Glowing Rod", "count", 2, 5000), ("Galaxy Rod", "count", 3, 40000) ] AVAILABLE_BAITS = [ # (name, max_difficulty) ("Worms", 30), ("Prawns", 50), ("Tuna", 70), ("Bass", 100) ] def onAppStart(app): # cmu_graphics entry point. Initialize ALL app.* state here: # - window/layout constants (width, height, dock/water/grass regions) # - app.creatures (from loadCreatures) # - app.save (from loadSave) and derived player values # - fishing state (idle / waiting-for-bite / skill-check / result) # - skill-check state placeholders (cursor angle, bars, speed, result) # - app.stepsPerSecond for the game loop cadence # Delegates to the smaller setup helpers below to keep this readable. #DRAWING CONSTANTS app.width = 600 app.height = 400 app.background = 'lightSkyBlue' app.waterColor = 'midnightBlue' app.grassColor = 'seaGreen' app.waterHeight = app.height // 3 app.waterTop = app.height - app.waterHeight app.grassHeight = 20 app.grassTop = app.height - app.waterHeight - app.grassHeight #GAME STATE/SETUP app.stepsPerSecond = 30 app.creatures = loadCreatures() app.save = loadSave() app.scene = "idle" # idle, waiting-for-bite, skill-check, result, shopping, menu #SKILL CHECK app.skillCheckAngle = 0 app.skillCheckSpeed = 1 app.skillCheckBars = [] # tuples of (position, length) app.skillCheckFailed = False #FISHING app.fishingTimer = 0 app.hookedFish = None #list of interactables with name and their positions #(name, sceneFilter, left, right, top, bottom) app.INTERACTABLES = [ ("open-shop", "idle", 400, 540, app.grassTop - 130, app.grassTop + 30), ("close-shop", "shop", app.width-50, app.width, 0, 50) ] #add all of our shop purchase upgrades startX = 75 startY = 100 endX = 525 endY = 250 colInd = 0 XDiff = (endX-startX) // 3 for columnType in ["power", "timing", "count"]: upgrades = [u for u in AVAILABLE_UPGRADES if u[1] == columnType] YDiff = (endY-startY) // len(upgrades) for i in range(len(upgrades)): app.INTERACTABLES.append(("upgrade-buy:" + upgrades[i][0], "shop", startX + XDiff * colInd, startX + XDiff * colInd + XDiff, startY + YDiff * i, startY + YDiff * i + YDiff)) colInd += 1 print(app.INTERACTABLES) def resetFishingState(app): # Return the fishing cycle to its starting "idle" point so a new cast can # begin. Clears any active skill-check data, the pending random bite timer, # and the currently-hooked creature. Called at startup and after each catch # is resolved/dismissed. app.skillCheckAngle = 0 #measured in degrees app.skillCheckSpeed = 1 app.skillCheckBars = [] # list of tuples of form (position, length) app.skillCheckFailed = False app.hookedFish = None app.fishingTimer = 0 app.scene = "idle" pass def pickRandomCreature(app): # Choose which creature the player hooks on this cast. Selection is filtered # by the current bait (bait gates which difficulty tiers are available) and # weighted toward easier creatures, while still giving rarer/harder ones a # real chance (per the spec's "skewed toward easier but still fun" goal). # Returns the chosen creature record (one entry from app.creatures). max_difficulty = [bait[1] for bait in AVAILABLE_BAITS if bait[0] == app.save["current_bait"]][0] #here we take the root of the difficulty to represent the weight of that fish, this makes higher difficulty fish easier to catch once unlocked k = 0.5 #invert the difficulty and add one so it avoids zero possibilities = [(creature["name"], (max_difficulty - creature["difficulty"] + 1) ** k) for creature in app.creatures if creature["difficulty"] <= max_difficulty] total = sum([c[1] for c in possibilities]) #now pick randomly roll = random.random() * (total - 0.0001) # a value between 0 and total (minus a small amount so we ensure the while loop triggers) while roll > 0: possibleCatch = possibilities.pop(0) roll -= possibleCatch[1] catch = [c for c in app.creatures if c["name"] == possibleCatch[0]][0] return catch def startSkillCheck(app, creature): # Configure a new skill check based on the hooked creature's difficulty. # Difficulty maps to: cursor rotation speed, number of target bars, and # bar length/width. Also apply any owned upgrades that modify these values # (e.g. slower cursor, wider bars, extra forgiveness). Stores the resulting # parameters on app for the controller/view to use. app.scene = "skill-check" difficulty = creature["difficulty"] fishingPower = 1 + sum([upgrade[2] for upgrade in AVAILABLE_UPGRADES if (upgrade[0] in app.save["owned_upgrades"] and upgrade[1] == "power")]) app.skillCheckSpeed = difficulty / fishingPower numBars = difficulty // 10 - sum([upgrade[2] for upgrade in AVAILABLE_UPGRADES if (upgrade[0] in app.save["owned_upgrades"] and upgrade[1] == "count")]) numBars = max(numBars, 1) lastPos = 0 lastLen = 0 while len(app.skillCheckBars) < numBars: #50 is default length, adjust it proportionally to difficulty, then add upgrade length multipliers. also set to int instead of float newLen = random.randrange(int((1 - (difficulty / 100)) * 50 * math.prod([upgrade[2] for upgrade in AVAILABLE_UPGRADES if (upgrade[0] in app.save["owned_upgrades"] and upgrade[1] == "timing")]))) #take the end of the last bar, and add a random amount between 5 degrees #and the remaining amount to the end divided by the number of bars left lastEndPos = (lastPos + lastLen) newPos = lastEndPos + random.randrange(5, (360 - lastEndPos) // (numBars - len(app.skillCheckBars))) #cap it to ensure we dont go over newPos = min(newPos, 360-newLen) #TODO: double check that multiple bars do not overlap app.skillCheckBars.append((newPos, newLen)) lastPos = newPos lastLen = newLen #speed is set, bars are set, scene is good, should be fine? def beginFishing(app): app.fishingTimer = random.random() * app.stepsPerSecond * 25 + 5 # completely random between 5 and 30 app.scene = "waiting-for-bite" # ============================================================================= # SECTION 3: CONTROLLER - GAME LOOP & INPUT HANDLING # ----------------------------------------------------------------------------- # cmu_graphics event hooks. These advance state and route input; they should # call Model helpers rather than contain heavy logic themselves. # ============================================================================= def onStep(app): # Runs every time step (the game loop / "controller tick"). # Responsibilities depend on the current fishing phase: # - waiting-for-bite: count down the random timer; when it elapses, pick a # creature and transition into the skill check. # - skill-check: advance the cursor's angle around the circle each step. # - otherwise (idle, viewing a catch, in shop): do nothing time-based. #had to look up how to do switch-case statement in python, I know it from JS though match app.scene: case "waiting-for-bite": app.fishingTimer -= 1 if app.fishingTimer < 1: selectedCreature = pickRandomCreature(app) app.hookedFish = selectedCreature startSkillCheck(app, selectedCreature) case "skill-check": app.skillCheckAngle += app.skillCheckSpeed / 6 #this was far too fast if (app.skillCheckAngle > 360): finishSkillCheck(app) def onMousePress(app, mouseX, mouseY): # Primary input router. Behavior depends on app.scene / fishing phase: # - dock idle: clicking starts a cast (begin waiting-for-bite). # - skill-check active: register the click as a timing attempt and # evaluate it via checkSkillHit. # - catch popup showing: dismiss the popup and reset fishing. # - shop / UI sprites clicked: open the shop or trigger that UI action. # Keep this as dispatch only - defer the real work to helper functions. match app.scene: case "idle" | "shop": #also had to look this up - pipe operator is dumb. just let me have multiple cases interacted = False #check if we clicked a hud element/interactable for name, sceneFilter, left, right, top, bottom in app.INTERACTABLES: if left < mouseX < right and top < mouseY < bottom \ and sceneFilter == app.scene: interacted = True match name: case "open-shop": app.scene = "shop" case "close-shop": app.scene = "idle" if name.startswith("upgrade-buy:"): print("buying upgrade", name) if not interacted and app.scene == "idle": beginFishing(app) case "result": sellCreature(app, app.hookedFish) resetFishingState(app) case "skill-check": checkSkillHit(app) case "waiting-for-bite": resetFishingState(app) def onKeyPress(app, key): # Optional keyboard controls. Candidates: a key to cast/reel as an # alternative to clicking, a key to open/close the shop, or a debug/reset # key during development. Remove if you decide the game is mouse-only. #decidedly unused for now, i prefer mouse-only. ill keep the definition here in case i need debug tools. # DEBUG if key == "m": app.save["cash"] = 1000000 if key == "escape": os._exit(0) #suggested by AI, instantly exits without hanging like sys.exit(0) #added by AI, but simply allows me to trigger a breakpoint manually to check state of app and variables #after some debate, this was the best option over something more complex if key == 'd': print("breakpoint here") def onMouseMove(app, mouseX, mouseY): global gmouseX, gmouseY gmouseX, gmouseY = mouseX, mouseY def checkSkillHit(app): # Evaluate a skill-check click: is the cursor's current angle inside one of # the active target bars? Decide hit vs miss. Define the multi-bar flow here # (e.g. must the player clear every bar, or is one good hit enough?) and # advance or fail the skill check accordingly. On full success, mark the # creature as caught and move to the catch-display phase; on failure, end # the cast without a catch. try: targetPos, targetLen = app.skillCheckBars.pop(0) except IndexError: targetPos = targetLen = -1 #make it impossible to succeed, this means they clicked after already getting rid of all bars if not ( 0 < (app.skillCheckAngle - targetPos) < targetLen): #check if we hit a bar #if we succeed, nothing needs to happen, we popped the bar #if we fail, we either didnt hit or missed a past bar, either way we can jump straight to the fail screen app.hookedFish = None app.scene = "result" print("failed",targetPos,targetLen,app.skillCheckAngle) def finishSkillCheck(app): if len(app.skillCheckBars) > 0: app.hookedFish = None #meaning we failed app.scene = "result" # ============================================================================= # SECTION 4: SHOP / ECONOMY LOGIC # ----------------------------------------------------------------------------- # Buying/selling and the rogue-like upgrade system. Pure state changes that the # controller invokes; no drawing here. # ============================================================================= def sellCreature(app, creature): # Add the creature's market value to the player's money. Value scales with # difficulty (harder creatures pay more, per the spec). Persist the updated # money via writeSave. Decide here whether selling is automatic on catch or # a deliberate action in the shop/market. #selling will be automatic if creature == None: return app.save["cash"] += creature["price"] writeSave(app.save) def buyUpgrade(app, upgradeId): # Attempt to purchase an upgrade. Check the player can afford it and doesn't # already own it; if valid, deduct money, record ownership in app.save, and # persist. Upgrades modify the skill check (cursor speed, bar size/count, # extra attempts, etc.) and are read back in startSkillCheck. upgrade = [u for u in AVAILABLE_UPGRADES if u[0] == upgradeId][0] #find the specified upgrade name, category, value, price = upgrade if name in app.save["owned_upgrades"]: return False if price > app.save["cash"]: return False app.save["owned_upgrades"].append(name) app.save["cash"] -= price def buyBaitLicense(app, baitId): # Purchase a one-time license for a bait tier. Once owned, the player can # freely switch to that bait at no further cost (switching handled by # setActiveBait). Bait determines which creatures are reachable in # pickRandomCreature. Deduct money, record ownership, persist. bait = [b for b in AVAILABLE_BAITS if b[0] == baitId][0] #find the specified upgrade name, max_difficulty, price = bait if name in app.save["owned_baits"]: return False if price > app.save["cash"]: return False app.save["owned_baits"].append(name) app.save["cash"] -= price def setActiveBait(app, baitId): # Switch the currently-equipped bait to any bait the player already owns a # license for. Free action. Updates app state so the next cast's creature # pool reflects the new bait. Reject the switch if the bait isn't owned. bait = [b for b in AVAILABLE_BAITS if b[0] == baitId][0] #find the specified upgrade name, max_difficulty, price = bait if name in app.save["owned_baits"]: app.save["current_bait"] = name writeSave(app.save) # ============================================================================= # SECTION 5: VIEW - DRAWING # ----------------------------------------------------------------------------- # redrawAll + per-scene draw helpers. These ONLY read state and draw; they must # never mutate app state. Composed top-down so redrawAll stays legible. # ============================================================================= def redrawAll(app): # Master draw routine. Draw the persistent world first (background, grass, # water, dock, player), then layer the active scene/phase on top: # - the shop UI, or # - the active skill-check overlay, or # - the catch-display popup. # Delegates each piece to the helpers below. drawBackground(app) if app.scene == "waiting-for-bite": drawBobber(app) if app.scene == "shop": drawShopEntities(app) if app.scene == "skill-check": drawSkillCheck(app) drawDebug(app) #my self-hosted gitea repo also stores the image files, very convienient BASE_IMAGE_URL = "https://gitea.lockeshor.com/lockeshor/15112-fishing-miniproject/raw/branch/main/sprites/" #REMINDERS: # drawRect(top, left, width, height, fill="color") # drawImage(url, ) def drawBackground(app): # Draw the static scene layout from the spec: grass behind, the dock, and # the lake occupying the bottom ~1/3 of the screen (blue water, optional # wave patterns). This is the backdrop present in every scene. #water drawRect(0, app.waterTop, app.width, app.waterHeight, fill=app.waterColor) #grass drawRect(0, app.grassTop, app.width, app.grassHeight, fill=app.grassColor) #dock dockColor = 'sienna' dockLegColor = 'maroon' drawRect(0,app.grassTop - 50, 200, 40, fill = dockColor) drawRect(40, app.grassTop - 10, 20, app.height, fill = dockLegColor) drawRect(90, app.grassTop - 10, 20, app.height, fill = dockLegColor) drawRect(140, app.grassTop - 10, 20, app.height, fill = dockLegColor) #image cred: https://pngtree.com/freepng/cartoon-style-illustration-of-a-young-sleeping-men-fishing-on-a-wood-chair_15775817.html #fisherman drawImage(BASE_IMAGE_URL + "fisherman.png", 60, app.grassTop - 50 - 140, width=140, height=170) #image cred: https://www.artstation.com/artwork/xzLbe2 #salesman drawImage(BASE_IMAGE_URL + "salesman.png", 400, app.grassTop - 130, width = 140, height= 160) #cash drawLabel(f"${app.save["cash"]}",app.width - 5,5,align="right-top",size=26,fill='darkgoldenrod',font='orbitron') def drawBobber(app): # Draw the character sprite seated at the end of the dock with the fishing # rod. When a cast is active, draw the line and the bobber dropping onto the # water surface. Reflects the current fishing phase visually. #cast line drawLine(172, 64, 370, 280, fill="white") #bobber? def drawShopEntities(app): # Draw the clickable shop/UI sprites standing on the grass (per the spec). # Positions here must line up with the hit-testing done in onMousePress so # clicks land on the right entity. #dimming drawRect(0,0,app.width,app.height,opacity=50) #menu background drawRect(50,50,app.width-100,app.height-100,fill='bisque',border='lightCoral',borderWidth=5) #exit "X" drawLine(app.width-45, 5, app.width-5, 45, fill='red', lineWidth=5) drawLine(app.width-5, 5, app.width-45, 45, fill='red', lineWidth=5) #draw upgrade purchases for name, sceneFilter, left, right, top, bottom in [interactable for interactable in app.INTERACTABLES if interactable[0].startswith("upgrade-buy:")]: print("drawing",name,left,right,top,bottom) drawRect(top, left, right - left, bottom - top, fill=None, border='black') drawLabel(name, top, left, align="top-left") def drawSkillCheck(app): # Draw the active skill-check overlay: the circle, the target bars placed # around it (count/length from difficulty), and the rotating cursor at its # current angle. Optionally show which bars are already cleared. Read-only. #IMPORTANT: circle is like unit circle for drawing, but we want it to have 0 at the top. transformations will be needed #radius of circle r = 90 #basic path #REMINDER: cx, cy, r drawCircle(300, 130, r, fill=None,border='dimGray') #bars for barPos, barLen in app.skillCheckBars: drawArc(300,130, r * 2 + 20, r * 2 + 20, -barPos + 90 - barLen, barLen, fill="white") #cover up center of bars drawCircle(300, 130, r - 10, fill=app.background) #cursor #REMINDER: width and height are for oval (so r * 2) #REMINDER: cx, cy, width, height, startangle, sweepangle drawArc(300,130, r * 2 + 20, r * 2 + 20, -app.skillCheckAngle + 90, 2, fill="red") def drawCatchDisplay(app): # Draw the "you caught it!" popup: the creature's sprite coming out of the # water, plus its name, description, and stats (length, weight, value). # Stays up until dismissed by a click (handled in onMousePress). Read-only. pass def drawHud(app): # Draw the persistent heads-up display: current money, equipped bait, and # any brief status/instruction text ("click to cast", etc.). Overlaid on # every scene so the player always sees their core stats. pass def drawDebug(app): debugStr = f"""\ mX: {gmouseX}, mY: {gmouseY} Scene: {app.scene} fishingTimer: {rounded(app.fishingTimer)} hookedFish: {app.hookedFish["name"] if app.hookedFish else "N/A"} @ {app.hookedFish["difficulty"] if app.hookedFish else "N/A"} difficulty """ debugLines = debugStr.splitlines() size = 16 for i in range(len(debugLines)): drawLabel(debugLines[i], 0, app.height - 125 + size * i, fill="white", size=size, align = "left") # ============================================================================= # SECTION 6: PROGRAM ENTRY # ----------------------------------------------------------------------------- def main(): # Start the cmu_graphics app (runApp with the chosen window dimensions). runApp() main()