DEV Community

Cover image for DEV LOG 39 - Continued Improvements
John
John

Posted on

DEV LOG 39 - Continued Improvements

Dev Log 39 - Continued Improvements

Hello All, I have continued to work on my project consistently since my last post, and this is my progress over the last few days, the tasks completed and those still underway.

Skill Auto-Generator Implemented (Major Pipeline Upgrade)

A Skill Auto Generator was fully built and integrated into Unity. This tool now automatically creates SkillDefinition ScriptableObjects based on my move data (skills & traits).

I added a custom EditorWindow under the Duckmon internal unity tab, which can now be used to generate skill data automatically, which outputs assets into Assets/SkillDef/.

Fixed folder creation logic, eliminating parent directory errors that appeared during this process.

All skills are not generating correctly, even the Multi-effect skills, and host their own audio effects (SFX) fields. Activation SFX support was added separately from hit SFX. (2 SFX per skill, a activation SFX and a effect/hit SFX)

Activation SFX Layer Added (Skill Personality Upgrade)
A new SFX layer was added for skill activation sounds, completely separate from hit SFX, damage SFX, and status SFX. This allows each skill to have its own activation identity, improving battle feel and presentation.

All fields including SkillName, EnergyCost, Element, TraitName, Power, IntScalingK, and Effects populate as intended.

Neutral and Electric move lists were successfully generated and now in the process of creating and working through the Fire based skillsets.

This has dramatically helped to speed up content creation, data input and ensures consistency across all skills.

Quax Trax Mode Created (New Music Feature)

A full music player mode named Quax Trax was implemented.

Includes a complete music library containing all Duckmon soundtrack content in a playable library area of my project.

Features play, pause, previous, next and stop controls. Includes 3 volume sliders, for SFX, Music Volume and MAster Volume , all plugging directly into my Master Audio Manager which is housed inside of the bootloader.

Supports playlists with duck-themed UI and an integrated audio visualizer.

This is a (semi) polished, player-friendly feature that adds charm and personality to the game along with some visual aspects to accompany the soundtrack.

Quax Trax Visualizer Implemented (Duck-Themed Audio Visualizer)

A custom 16-bar mirrored audio visualizer was created for Quax Trax.

Features 16 bars total, with 8 on the left and 8 on the right in mirrored movement for symmetry.

A splash effect is triggered under each bar when bar height increases.

Uses transparency for a soft glow, adding motion and fluidity.

Includes a duck icon overlay positioned above the bars so the bars rise behind the duck, creating the illusion that the duck is flying upward while visually hiding behind the duck for a clean layered look.

Bars use the splash texture as fill, making them transparent, glowing, reactive, and more organic than plain rectangles.

Fully synced to music playback in Quax Trax.

Battle System Planning (Modular Rewrite Scheduled)

Reviewed the full buff and status design document and identified required updates for the battle engine. These skills/traits/moves/buffs and debuffs are still in development and will be finalized and balanced later once playtesting is able to be completed.

I have decided to fully generate all skills first via CSV, then finalize the battle system. Doing this for all 11 factions, thus saving the constant rewriting of datasets and scripts to match new entries and conditions/rules.

Expanded Reasoning: More systems are still being added such as revives, time limit effects, counter effects, other DOT skills, passives and skill specific recoveries and effects. The skill roster is expanding, and buff or debuff categories are still evolving. This approach avoids rewriting the battle engine multiple times. Once the full skill list exists, the system can be finalized cleanly.

Systems Queued for Modular Updates: SkillExecutor, Buffs, Statuses, TeamBuffs, EnergySystem, TurnManager, and minor updates to DamageCalculator. All Currently existing but will need major updates.

This ensures the battle engine will match the full intended design once all skills exist.

Google Sheets to Python Data Pipeline Integration
(Major Tooling Foundation)

Set up a dedicated Google Cloud project to support automated data access for Duckmon development tools.

Enabled the Google Sheets API and created a service account for secure programmatic access. (Inspiration from a fellow Dev user post)

Granted the service account permission to read and write to the Duckmon card database sheet inside google sheets.

Generated a local authentication key stored privately and referenced only by path.

The service account acts as a robot user with Editor permissions on the sheet, requiring no OAuth popups.

Created a Python tool named sheetbot.py that connects directly to the Duckmon card spreadsheet ID, reading the entire dataset of 311 rows up to column AP, and printing card data directly into the console.

This pipeline allows instant card row retrieval for AI integration, lore writing, and balancing without scrolling through Google Sheets manually.

Python Vertical Card Viewer (Major Data Inspection Upgrade)

Edited the Python-powered code card inspection tool, which is now able to read the entire Duckmon card database directly from Google Sheets and display each card one at a time, fully expanded, vertically, with zero truncation inside a seperate window.

Shows one Duckmon per page, printing every column vertically with no cut-offs. Showing this...

Rather than this...

This window now displays full Skill 1 through 4 breakdowns including power, cost, type, traits, chances, and durations much more clearly.

Includes stats, metadata, quotes, and avatar IDs, while lore is intentionally excluded for cleaner developer-focused output.

Integrates a search system by skill name, trait or effect type, rarity, faction, or any column, alongside an option to show all cards.

Features curses UI with arrow-key navigation, DELETE to exit views, a clean menu system, and smooth scrolling.

This serves as a massive improvement in data readability, perfect for balancing skills, checking trait consistency, reviewing metadata, debugging CSV entries, and feeding clean card data into AI for design work. The ability to directly search for a single keyword on any card/unit or to search directly for any trait name or skill name will be a huge time saver in completed this task.

Identified some bugs in the code:

Fixed Missing select_column(): The menu contained options that called a function that was never defined, causing a crash. A full implementation was added with arrow-key navigation, safe cancellation with DELETE, and width-safe rendering.

Removed Silent Search Failure: Zero-match searches previously fell back to showing all cards. This was replaced with a clear no matches found message and a pause until a key is pressed.

Refactored Search System: search_columns and search_single_column were combined into one unified search function supporting lists, reducing duplicated code.

Added Width-Safe Rendering: Long values that exceeded terminal width now truncate safely, preventing curses exceptions and screen crashes.

Final Polish: The viewer reliably displays all stats, metadata, skills, effects, chances, durations, avatar IDs, and quote IDs while keeping lore excluded.

Python Vertical Card Viewer: Real-Time Editor Mode Added

The Python Vertical Card Viewer has evolved and expanded its use cases, going from what was a read-only inspection tool into a full real-time Duckmon database editor capable of writing directly to Google Sheets from inside the terminal.

Live Editing Inside the Terminal: Pressing SPACE enters Editor Mode for the current card. UP and DOWN cycle through all fields inside the card. Pressing ENTER edits the selected field, writing the new value immediately to Google Sheets via values.update calls using column-letter mapping, safe row indexing, and immediate local data sync.

Practical Development Benefits: Allows real-time balancing of Skill 1 through 4 values like power, cost, type, traits, chances, and durations without tabbing between Unity, Sheets, and CSVs. Instantly corrects card names, factions, rarity, stats, quotes, and avatar IDs. This creates a zero-friction iteration loop where you can test a Duckmon in Unity, alt-tab to the terminal, adjust its stats, save instantly, and re-run the battle flow.

Workflow Details: Features inline field editing without popups or clutter, highlighted field navigation, safe exit flows using DELETE, and lore exclusion to keep the focus on gameplay-critical data.

Here is the script if anyone wants to repurpose it for there own needs;

NOTES

A heads up before anyone dives in, this is a personal tool i built for my own card sheet so it is rough around the edges in places, but it works fine for my use case.

The Credentials path / sheet id / spreadsheet id are redacted obviously, swap those with your own service account json + sheet info before running

get_service() reconnects to the api every time you edit a field instead of caching the connection. Not ideal if you are editing a ton of fields back to back but for me it has never been an issue.

The HEADER_TO_COL dict is hardcoded to match my sheet's column layout. if you use this and reorder / add columns in your sheet you NEED to update this map too or it'll start writing values to the wrong cells. This is the main thing to watch out for if you fork it

Edits in editor mode save straight to the sheet the second you hit enter, no confirmation / undo. Works for me since its just my personal card db but if that makes you nervous maybe add a confirm step

Ironically, "Inteligence" is spelled wrong in the header on purpose (well, not on purpose, just never fixed it). Do not "fix" the typo in the code unless you fix it in your sheet too or the lookup will break.

No real error handling if the api call fails mid-edit (bad connection etc), it will just kick and curses will dump you back to your terminal, never happened to me but you have been warned.

The key selection for: delete key = go back / cancel everywhere instead of esc, just a personal preference thing.

This was built on unix, if you are on windows you will need windows-curses installed for this to even run. (pip install windows-curses)

import curses
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build

# -----------------------------
# GOOGLE SHEETS CONFIG
# -----------------------------

# NOTE: All sensitive values (spreadsheet ID, credentials path, sheet name)
# have been intentionally redacted for security.

SPREADSHEET_ID = Redacted
CREDENTIALS_PATH = Redacted
SHEET_NAME = Redacted
RANGE = f"{SHEET_NAME}!A:AP"

# -----------------------------
# COLUMN LETTER MAP
# -----------------------------
HEADER_TO_COL = {
    "Card Number": "A",
    "Card Name": "B",
    "Card Faction": "C",
    "Card Rarity": "D",
    "Attack Top Value": "E",
    "Defense Bottom Value": "F",
    "Inteligence Left Value": "G",
    "Dexterity Right Value": "H",
    "Total Stats Value": "I",
    "Gold Sell Value": "J",
    "Avatar ID": "K",
    "Quote ID": "L",
    "QuoteText": "M",
    "Card Lore": "N",
    "Skill 1": "O",
    "Skill 1 Energy Cost": "P",
    "Skill 1 Energy Type": "Q",
    "Skill 1 Effect Type": "R",
    "Skill 1 Effect Chance": "S",
    "Skill 1 Effect Duration": "T",
    "Skill 1 Attack Power": "U",
    "Skill 2": "V",
    "Skill 2 Energy Cost": "W",
    "Skill 2 Energy Type": "X",
    "Skill 2 Effect Type": "Y",
    "Skill 2 Effect Chance": "Z",
    "Skill 2 Effect Duration": "AA",
    "Skill 2 Attack Power": "AB",
    "Skill 3": "AC",
    "Skill 3 Energy Cost": "AD",
    "Skill 3 Energy Type": "AE",
    "Skill 3 Effect Type": "AF",
    "Skill 3 Effect Chance": "AG",
    "Skill 3 Effect Duration": "AH",
    "Skill 3 Attack Power": "AI",
    "Skill 4": "AJ",
    "Skill 4 Energy Cost": "AK",
    "Skill 4 Energy Type": "AL",
    "Skill 4 Effect Type": "AM",
    "Skill 4 Effect Chance": "AN",
    "Skill 4 Effect Duration": "AO",
    "Skill 4 Attack Power": "AP",
}

# -----------------------------
# SHEETS SERVICE
# -----------------------------
def get_service():
    creds = Credentials.from_service_account_file(
        CREDENTIALS_PATH,
        scopes=["https://www.googleapis.com/auth/spreadsheets"]
    )
    return build("sheets", "v4", credentials=creds)

# -----------------------------
# LOAD GOOGLE SHEETS
# -----------------------------
def load_sheet():
    service = get_service()
    sheet = service.spreadsheets().values().get(
        spreadsheetId=SPREADSHEET_ID,
        range=RANGE
    ).execute()

    values = sheet.get("values", [])
    if not values:
        return [], []

    headers = values[0]
    rows = []

    for row in values[1:]:
        row_dict = {}
        for i, h in enumerate(headers):
            row_dict[h] = row[i] if i < len(row) else ""
        rows.append(row_dict)

    return rows, headers

# -----------------------------
# UPDATE CELL
# -----------------------------
def update_cell(row_index, header_name, new_value):
    if header_name not in HEADER_TO_COL:
        return
    col_letter = HEADER_TO_COL[header_name]
    service = get_service()
    body = {"values": [[new_value]]}
    # +2 because row_index is 0-based and row 1 is headers
    sheet_row = row_index + 2
    rng = f"{SHEET_NAME}!{col_letter}{sheet_row}"
    service.spreadsheets().values().update(
        spreadsheetId=SPREADSHEET_ID,
        range=rng,
        valueInputOption="USER_ENTERED",
        body=body
    ).execute()

# -----------------------------
# INPUT BOX
# -----------------------------
def prompt_input(stdscr, prompt):
    stdscr.clear()
    h, w = stdscr.getmaxyx()
    stdscr.addstr(1, 2, prompt[:w-4])
    stdscr.addstr(3, 2, "> ")
    curses.echo()
    s = stdscr.getstr(3, 4, 120).decode("utf-8")
    curses.noecho()
    return s

# -----------------------------
# COLUMN SELECTOR
# -----------------------------
def select_column(stdscr, columns):
    selected = 0
    while True:
        stdscr.clear()
        h, w = stdscr.getmaxyx()
        stdscr.addstr(1, 2, "SELECT COLUMN (UP/DOWN, ENTER, DELETE = cancel)")
        for i, col in enumerate(columns):
            y = 3 + i
            if y >= h - 1:
                break
            label = col[:w-6]
            if i == selected:
                stdscr.attron(curses.A_REVERSE)
                stdscr.addstr(y, 4, label)
                stdscr.attroff(curses.A_REVERSE)
            else:
                stdscr.addstr(y, 4, label)
        stdscr.refresh()
        key = stdscr.getch()
        if key == curses.KEY_UP:
            selected = (selected - 1) % len(columns)
        elif key == curses.KEY_DOWN:
            selected = (selected + 1) % len(columns)
        elif key in (curses.KEY_ENTER, 10, 13):
            return columns[selected]
        elif key == curses.KEY_DC:
            return None

# -----------------------------
# UNIFIED SEARCH
# -----------------------------
def search(rows, columns, term):
    term = term.lower().strip()
    results = []
    for row in rows:
        for col in columns:
            if col in row:
                raw = str(row[col]).lower()
                parts = [p.strip() for p in raw.split(",")]
                if any(term in p for p in parts):
                    results.append(row)
                    break
    return results

# -----------------------------
# VIEWER (READ-ONLY)
# -----------------------------
def viewer(stdscr, rows, headers):
    pos = 0
    while True:
        stdscr.clear()
        h, w = stdscr.getmaxyx()
        stdscr.addstr(0, 2, "VERTICAL CARD VIEWER (UP/DOWN scroll, DELETE = back)")

        if pos >= len(rows):
            pos = len(rows) - 1

        row = rows[pos]

        y = 2
        for hname in headers:
            if hname.lower() == "card lore":
                continue
            value = str(row.get(hname, ""))
            line = f"{hname}: {value}"
            stdscr.addstr(y, 2, line[:w-4])
            y += 1
            if y >= h - 2:
                break

        stdscr.addstr(h - 2, 2, f"Card {pos+1}/{len(rows)}")
        stdscr.refresh()

        key = stdscr.getch()

        if key == curses.KEY_UP:
            pos = max(0, pos - 1)
        elif key == curses.KEY_DOWN:
            pos = min(len(rows) - 1, pos + 1)
        elif key == curses.KEY_DC:
            break

# -----------------------------
# VIEWER WITH EDITOR MODE
# -----------------------------
def viewer_editor(stdscr, rows, headers):
    pos = 0
    editing = False
    field_index = 0

    # build list of editable headers (exclude lore)
    editable_headers = [h for h in headers if h.lower() != "card lore"]

    while True:
        stdscr.clear()
        h, w = stdscr.getmaxyx()
        if not editing:
            stdscr.addstr(0, 2, "VERTICAL CARD VIEWER (UP/DOWN cards, SPACE = edit, DELETE = back)")
        else:
            stdscr.addstr(0, 2, "EDITOR MODE (UP/DOWN fields, ENTER = edit, DELETE = exit editor)")

        if pos >= len(rows):
            pos = len(rows) - 1

        row = rows[pos]

        y = 2
        for idx, hname in enumerate(editable_headers):
            value = str(row.get(hname, ""))
            line = f"{hname}: {value}"
            if editing and idx == field_index:
                stdscr.attron(curses.A_REVERSE)
                stdscr.addstr(y, 2, line[:w-4])
                stdscr.attroff(curses.A_REVERSE)
            else:
                stdscr.addstr(y, 2, line[:w-4])
            y += 1
            if y >= h - 2:
                break

        stdscr.addstr(h - 2, 2, f"Card {pos+1}/{len(rows)}")
        stdscr.refresh()

        key = stdscr.getch()

        if not editing:
            if key == curses.KEY_UP:
                pos = max(0, pos - 1)
            elif key == curses.KEY_DOWN:
                pos = min(len(rows) - 1, pos + 1)
            elif key == ord(' '):
                editing = True
                field_index = 0
            elif key == curses.KEY_DC:
                break
        else:
            if key == curses.KEY_UP:
                field_index = (field_index - 1) % len(editable_headers)
            elif key == curses.KEY_DOWN:
                field_index = (field_index + 1) % len(editable_headers)
            elif key in (curses.KEY_ENTER, 10, 13):
                header_name = editable_headers[field_index]
                prompt = f"Enter new value for {header_name}:"
                new_val = prompt_input(stdscr, prompt)
                # update local data
                rows[pos][header_name] = new_val
                # write back to sheet
                update_cell(pos, header_name, new_val)
            elif key == curses.KEY_DC:
                editing = False

# -----------------------------
# MAIN
# -----------------------------
def main(stdscr):
    curses.curs_set(0)
    rows, headers = load_sheet()

    if not rows:
        stdscr.clear()
        stdscr.addstr(2, 2, "ERROR: No data loaded from Google Sheets.")
        stdscr.addstr(4, 2, "Press any key to exit.")
        stdscr.refresh()
        stdscr.getch()
        return

    menu_options = [
        "Search Skill Name (Skill 1–4)",
        "Search Skill Effect Type / Trait (Skill 1–4)",
        "Search Rarity",
        "Search Faction",
        "Search Card Fields (Number, Name, Stats, Gold)",
        "Search ANY Column",
        "Show ALL Cards",
        "Show ALL Cards (Editor Mode)",
        "Exit",
    ]

    selected = 0

    while True:
        stdscr.clear()
        h, w = stdscr.getmaxyx()
        stdscr.addstr(1, 2, "MASTER READER — SEARCH MENU (UP/DOWN, ENTER, DELETE)")
        for i, opt in enumerate(menu_options):
            y = 3 + i
            label = opt[:w-6]
            if i == selected:
                stdscr.attron(curses.A_REVERSE)
                stdscr.addstr(y, 4, label)
                stdscr.attroff(curses.A_REVERSE)
            else:
                stdscr.addstr(y, 4, label)
        stdscr.refresh()

        key = stdscr.getch()

        if key == curses.KEY_UP:
            selected = (selected - 1) % len(menu_options)
        elif key == curses.KEY_DOWN:
            selected = (selected + 1) % len(menu_options)
        elif key in (curses.KEY_ENTER, 10, 13):
            choice = menu_options[selected]

            if choice.startswith("Search Skill Name"):
                term = prompt_input(stdscr, "Enter skill name:")
                results = search(rows, ["Skill 1", "Skill 2", "Skill 3", "Skill 4"], term)
                if not results:
                    stdscr.clear()
                    stdscr.addstr(2, 2, "No matches found. Press any key.")
                    stdscr.refresh()
                    stdscr.getch()
                    continue
                viewer(stdscr, results, headers)

            elif choice.startswith("Search Skill Effect Type"):
                term = prompt_input(stdscr, "Enter skill effect type / trait:")
                results = search(rows, [
                    "Skill 1 Effect Type",
                    "Skill 2 Effect Type",
                    "Skill 3 Effect Type",
                    "Skill 4 Effect Type"
                ], term)
                if not results:
                    stdscr.clear()
                    stdscr.addstr(2, 2, "No matches found. Press any key.")
                    stdscr.refresh()
                    stdscr.getch()
                    continue
                viewer(stdscr, results, headers)

            elif choice.startswith("Search Rarity"):
                term = prompt_input(stdscr, "Enter rarity:")
                results = search(rows, ["Card Rarity"], term)
                if not results:
                    stdscr.clear()
                    stdscr.addstr(2, 2, "No matches found. Press any key.")
                    stdscr.refresh()
                    stdscr.getch()
                    continue
                viewer(stdscr, results, headers)

            elif choice.startswith("Search Faction"):
                term = prompt_input(stdscr, "Enter faction:")
                results = search(rows, ["Card Faction"], term)
                if not results:
                    stdscr.clear()
                    stdscr.addstr(2, 2, "No matches found. Press any key.")
                    stdscr.refresh()
                    stdscr.getch()
                    continue
                viewer(stdscr, results, headers)

            elif choice.startswith("Search Card Fields"):
                col = select_column(stdscr, [
                    "Card Number", "Card Name", "Card Faction", "Card Rarity",
                    "Attack Top Value", "Defense Bottom Value",
                    "Inteligence Left Value", "Dexterity Right Value",
                    "Total Stats Value", "Gold Sell Value"
                ])
                if col:
                    term = prompt_input(stdscr, f"Enter search term for {col}:")
                    results = search(rows, [col], term)
                    if not results:
                        stdscr.clear()
                        stdscr.addstr(2, 2, "No matches found. Press any key.")
                        stdscr.refresh()
                        stdscr.getch()
                        continue
                    viewer(stdscr, results, headers)

            elif choice.startswith("Search ANY Column"):
                col = select_column(stdscr, headers)
                if col:
                    term = prompt_input(stdscr, f"Enter search term for {col}:")
                    results = search(rows, [col], term)
                    if not results:
                        stdscr.clear()
                        stdscr.addstr(2, 2, "No matches found. Press any key.")
                        stdscr.refresh()
                        stdscr.getch()
                        continue
                    viewer(stdscr, results, headers)

            elif choice == "Show ALL Cards":
                viewer(stdscr, rows, headers)

            elif choice == "Show ALL Cards (Editor Mode)":
                viewer_editor(stdscr, rows, headers)

            elif choice.startswith("Exit"):
                break

        elif key == curses.KEY_DC:
            break

if __name__ == "__main__":
    curses.wrapper(main)



Enter fullscreen mode Exit fullscreen mode

I Will continue to provide updates on my progress for any interested parties, and for my own sanity, :)

Top comments (0)