Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Normalize text files to LF in the repo and working tree.
* text=auto eol=lf

# Windows batch scripts need CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf

*.png binary
*.ico binary
*.exe binary
*.dll binary
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: python -m pip install -r requirements.txt pytest
- name: Run tests
run: python -m pytest -q
- name: Run UI tests
run: node --test tests/ww_moves.test.js tests/ww_rotations.test.js

build:
# Builds the Windows release folder so it can be downloaded from the run's
# Artifacts section without a local build.
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: python -m pip install -r requirements.txt -r requirements-build.txt
- name: Build
run: python -m PyInstaller --noconfirm --clean ComboTracker.spec
- uses: actions/upload-artifact@v4
with:
name: ComboTracker-windows
path: dist/ComboTracker/
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ combos.json.bak

# Runtime profiler artifacts
/logs/
ww_library_cache/

# Your saved combos, teams and stats (combos.example.json seeds it on first run)
combos.json

# pytest
.pytest_cache/

# Raw encore.moe snapshot (Characters page "Download raw data"); source for tools/ww_build_moves.py
/data/encore_raw/
119 changes: 31 additions & 88 deletions Game_Wuthering_Waves.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@
from typing import Any
from uuid import uuid4

# Defaults for a fresh install (no combos.json yet), tuned for Wuthering Waves:
# swapping characters (1/2/3), Resonance Skill (e), Liberation (r) and jumping end a combo
# unless they are the expected input. Values are ender cooldowns in ms.
WW_DEFAULT_COMBO_ENDERS: dict[str, int] = {"1": 1000, "2": 1000, "3": 1000, "e": 2000, "r": 2000, "space": 2000}
# Jumping during a hold (e.g. a charged heavy attack) does not drop the combo.
WW_DEFAULT_COMBO_ENDERS_SOFT: frozenset[str] = frozenset({"space"})
# Inputs auto-transcribe records: interact/skill/echo/liberation, attack/dodge, swaps, jump, sprint.
WW_DEFAULT_TRANSCRIBE_KEYS = "f, e, q, r, lmb, rmb, 1, 2, 3, space, shift"


@dataclass
class WutheringWavesGame:
"""
Wuthering Waves specific state + helpers.

This module exists to keep `ComboTrackerEngine` focused on core combo parsing/tracking,
while game-specific metadata (target game, WW teams/presets, WW combo->team mappings)
while game-specific metadata (WW teams/presets, WW combo->team mappings)
lives in one place.
"""

# Per-combo target game ("generic" | "wuthering_waves")
combo_target_game: dict[str, str] = field(default_factory=dict)

# Team presets (slot-based; characters are stored in ww_characters)
# ww_teams: team_id -> {
# "name": str,
Expand All @@ -28,7 +34,7 @@ class WutheringWavesGame:
ww_teams: dict[str, dict[str, Any]] = field(default_factory=dict)
ww_active_team_id: str | None = None

# Per-combo assigned team (when target_game = wuthering_waves)
# Per-combo assigned team
combo_ww_team: dict[str, str] = field(default_factory=dict)

# Character library: name_lower -> { name, swap_image, lmb_image, ability_images: {e,q,r} }
Expand Down Expand Up @@ -67,71 +73,29 @@ def can_ender_drop_combo(
return False
if not getattr(engine, "last_input_time", None) and getattr(engine, "current_index", 0) == 0:
return False
if self.get_target_game(getattr(engine, "active_combo_name", None) or "") == "wuthering_waves":
key = (input_name or "").strip().lower()
if key in self.WW_CHARACTER_SLOTS and self.ww_active_character and key == self.ww_active_character:
return False
key = (input_name or "").strip().lower()
if key in self.WW_CHARACTER_SLOTS and self.ww_active_character and key == self.ww_active_character:
return False
return True

def on_accepted_key(self, engine: Any, input_name: str) -> None:
"""When the correct key is 1/2/3 and game is WW, track active character for ender logic."""
if self.get_target_game(getattr(engine, "active_combo_name", None) or "") != "wuthering_waves":
return
"""When the correct key is 1/2/3, track the active character for ender logic."""
key = (input_name or "").strip().lower()
if key in self.WW_CHARACTER_SLOTS:
self.ww_active_character = key

def get_target_game(self, combo_name: str) -> str:
name = (combo_name or "").strip()
g = str(self.combo_target_game.get(name, "generic") or "generic").strip().lower()
return g if g in ("generic", "wuthering_waves") else "generic"

def set_target_game(self, combo_name: str, target_game: str | None):
name = (combo_name or "").strip()
g = str(target_game or "").strip().lower()
if not name:
return
if g in ("generic", "wuthering_waves"):
self.combo_target_game[name] = g
else:
self.combo_target_game.pop(name, None)

def apply_combo_team_assignment(self, combo_name: str, *, target_game: str, ww_team_id: str | None):
"""
Apply per-combo WW team assignment.
Expected to be called after `set_target_game()`.
"""
def apply_combo_team_assignment(self, combo_name: str, *, ww_team_id: str | None):
"""Apply per-combo WW team assignment."""
name = (combo_name or "").strip()
if not name:
return
g = str(target_game or "generic").strip().lower()
if g == "wuthering_waves":
tid = str(ww_team_id or "").strip()
if tid and tid in self.ww_teams:
self.combo_ww_team[name] = tid
self.ww_active_team_id = tid
else:
self.combo_ww_team.pop(name, None)
tid = str(ww_team_id or "").strip()
if tid and tid in self.ww_teams:
self.combo_ww_team[name] = tid
self.ww_active_team_id = tid
else:
self.combo_ww_team.pop(name, None)

def rename_combo(self, old_name: str, new_name: str):
old = (old_name or "").strip()
new = (new_name or "").strip()
if not old or not new or old == new:
return
if old in self.combo_target_game and new not in self.combo_target_game:
self.combo_target_game[new] = self.combo_target_game.pop(old)
if old in self.combo_ww_team and new not in self.combo_ww_team:
self.combo_ww_team[new] = self.combo_ww_team.pop(old)

def delete_combo(self, name: str):
cname = (name or "").strip()
if not cname:
return
self.combo_target_game.pop(cname, None)
self.combo_ww_team.pop(cname, None)

# -------------------------
# Character library helpers
# -------------------------
Expand Down Expand Up @@ -166,12 +130,11 @@ def _resolved_team_images(self, team_data: dict[str, Any]) -> tuple[dict, dict,
# Editor payload helpers
# -------------------------

def editor_payload(self, combo_name: str, target_game_override: str | None = None) -> dict[str, Any]:
def editor_payload(self, combo_name: str) -> dict[str, Any]:
"""
Build the WW-related section of the editor payload for the frontend.
"""
name = (combo_name or "").strip()
target_game = str(target_game_override).strip().lower() if target_game_override else self.get_target_game(name)

# Build teams list with slot info
ww_teams = []
Expand All @@ -189,11 +152,10 @@ def editor_payload(self, combo_name: str, target_game_override: str | None = Non

# Selected team: active team > combo assignment > none
sel_team_id = ""
if target_game == "wuthering_waves":
if self.ww_active_team_id and self.ww_active_team_id in self.ww_teams:
sel_team_id = self.ww_active_team_id
elif name and name in self.combo_ww_team and self.combo_ww_team[name] in self.ww_teams:
sel_team_id = self.combo_ww_team[name]
if self.ww_active_team_id and self.ww_active_team_id in self.ww_teams:
sel_team_id = self.ww_active_team_id
elif name and name in self.combo_ww_team and self.combo_ww_team[name] in self.ww_teams:
sel_team_id = self.combo_ww_team[name]

team_name = ""
team_slots = {"slot1": "", "slot2": "", "slot3": ""}
Expand Down Expand Up @@ -231,7 +193,8 @@ def editor_payload(self, combo_name: str, target_game_override: str | None = Non
})

return {
"target_game": target_game,
# The current UI still reads this; every combo is Wuthering Waves.
"target_game": "wuthering_waves",
"ww_teams": ww_teams,
"ww_team_id": sel_team_id,
"ww_team_name": team_name,
Expand Down Expand Up @@ -396,33 +359,13 @@ def delete_ww_team(engine, team_id: str) -> tuple[bool, str | None]:
return True, None


def select_team_stateless(engine, team_id: str, target_game: str) -> None:
def select_team_stateless(engine, team_id: str) -> None:
"""Stateless team selection - doesn't persist, just updates UI."""
tid = str(team_id or "").strip()
game = str(target_game or "generic").strip().lower()

if game == "wuthering_waves":
engine.ww.set_active_ww_team(tid)
engine._send({"type": "combo_data", **engine.get_editor_payload(target_game_override=game)})
engine._send({"type": "timeline_update", "steps": engine.timeline_steps()})
return

engine.ww.set_active_ww_team("")
engine._send({"type": "combo_data", **engine.get_editor_payload(target_game_override=game)})
engine.ww.set_active_ww_team(str(team_id or "").strip())
engine._send({"type": "combo_data", **engine.get_editor_payload()})
engine._send({"type": "timeline_update", "steps": engine.timeline_steps()})


def update_target_game_stateless(engine, target_game: str) -> None:
"""Stateless target game update - doesn't persist, just updates UI."""
game = str(target_game or "generic").strip().lower()
if game not in ("generic", "wuthering_waves"):
game = "generic"

payload = engine.get_editor_payload(target_game_override=game)
payload["target_game"] = game
engine._send({"type": "combo_data", **payload})


def save_ww_character_cmd(
engine,
*,
Expand Down
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
## ComboTracker
## ComboTracker for Wuthering Waves
![ComboTracker_AutoScroll](https://github.com/user-attachments/assets/b011d51d-d7c3-45e8-ae52-5e643f254e16)

A small local web UI + Python backend that listens to your keyboard/mouse and tracks whether you performed a defined “combo” correctly, including **wait** and **hold** timing steps.
A combo trainer built for **Wuthering Waves**. A small local web UI + Python backend listens to your keyboard/mouse and tracks whether you performed a rotation correctly, including **wait** and **hold** timing steps.

Every combo is tied to a team of three Resonators: the timeline shows each character's portrait on swaps (`1`/`2`/`3`) and their own Basic Attack, Resonance Skill (`e`), Echo (`q`) and Liberation (`r`) icons. A fresh install comes with WuWa default combo enders (swaps, `e`, `r`, jump) and auto-transcribe keys.

![Combo Input](https://github.com/user-attachments/assets/30466399-8db6-474d-8508-b90143143ab7)

Expand All @@ -11,6 +13,8 @@ For example, shorekeeper's Liberation is 2.9s in real time, but has a in game fr

### Features
- **Practice combos**: see live status + a step timeline.
- **One-click characters**: in **Team & Characters**, type a name (e.g. `Phrolova`) and press **Add from wuthering.gg** to download their portrait and skill icons. **Refresh all icons** re-downloads icons for every saved character. Needs an internet connection.
- **Move names**: each step is labeled with the move it performs, like "Zani Basic 2", "Phoebe Intro" or "Tune Break", using your team slots. A leading `f` is the fight-start prompt; any later `f` is Tune Break. Toggle with **Move names** above the timeline.
- **Wait + hold steps**:
- `wait` = minimum delay gate (pressing later is OK).
- `hold` = finger commitment (must hold long enough).
Expand Down Expand Up @@ -56,11 +60,13 @@ python -m pip install -r requirements.txt

```bash
cd ComboTracker
python ui_server.py
npm run serve
```

(`npm run serve` just runs `python ui_server.py`, which also works without Node.)

Then open the UI:
- `http://localhost:8080`
- `http://localhost:8737`

Notes:
- The backend also runs a WebSocket server at `ws://localhost:8765`.
Expand All @@ -78,8 +84,10 @@ You can package ComboTracker so others can run it without installing Python.
python -m PyInstaller --noconfirm --clean ComboTracker.spec
```
3. Release artifact: **`dist/ComboTracker/`**. Keep the entire folder together and launch **`dist/ComboTracker/ComboTracker.exe`**. Windows will prompt for **Administrator** approval once per launch; this matches elevated games so global input capture works in-game.
4. Open **`http://localhost:8080`** in your browser. A console window stays open with the server URL (close it or press Ctrl+C to stop).
5. **`combos.json`** (saved combos and settings) is written **next to the .exe**. Keep it in the release folder if you want saved data to move with the app; otherwise a new `combos.json` is created on first run.
4. Open **`http://localhost:8737`** in your browser. A console window stays open with the server URL (close it or press Ctrl+C to stop).
5. **`combos.json`** (saved combos and settings) is written **next to the .exe**. Keep it in the release folder if you want saved data to move with the app; otherwise a new `combos.json` is created on first run. Updating the app never touches it: the source download ships `combos.example.json`, which only seeds `combos.json` the first time.

**Without a local build:** every push runs the **CI** workflow on GitHub Actions, which runs the tests and builds the Windows release. Download it from the workflow run's **Artifacts** section (`ComboTracker-windows`).

**Publishing a release:** zip and ship the complete `dist/ComboTracker/` folder. The one-folder, non-UPX layout avoids the self-extracting executable pattern that commonly triggers antivirus heuristics. Build on a **normal (non-elevated)** terminal; PyInstaller may warn if you run it from an elevated shell. Code-signing the executable is recommended for public releases.

Expand All @@ -94,7 +102,7 @@ You can show the **Combo Steps** timeline in OBS as a separate overlay (e.g. for
https://github.com/user-attachments/assets/0639590a-1de7-43b0-9ec8-b7eda59a833e

1. In the main UI, open the **Combo Steps** section and click **Copy Overlay URL**.
2. In OBS, add a **Browser Source**, paste the URL (e.g. `http://localhost:8080/?view=timeline`), and set the width/height you want. The overlay stays in sync with the app via WebSocket.
2. In OBS, add a **Browser Source**, paste the URL (e.g. `http://localhost:8737/?view=timeline`), and set the width/height you want. The overlay stays in sync with the app via WebSocket, and reconnects on its own if you restart ComboTracker.
3. **Open in new window** is also available if you prefer OBS **Window Capture** instead of Browser Source.

**Browser Source vs your browser:** The OBS Browser Source is a separate embedded browser. Toggles (Auto scroll, Images, Show fail count) are per-instance: use **Interact** on the Browser Source in OBS (right‑click the source → Interact) to open a window where you can click those controls for the overlay. Timeline content and progress sync for all clients; only the toggle state is local to each instance.
Expand All @@ -106,6 +114,23 @@ For a walkthrough of common OBS Browser Source gotchas (separate instance, inter
you can set custom CSS to zoom in
`body {zoom : 150%;}`

## Live key overlay

A built-in, NohBoard-style key display for streams and recordings: WASD, Shift, Space, 1/2/3, Q/E/R/F and both mouse buttons light up as you press them. The swap keys show your selected team's character names and portraits, and macro playback shows up too.

- Open it from **Settings > Stream overlays**, or add `http://localhost:8737/keys.html` as an OBS **Browser Source**. The background is transparent.
- It reconnects on its own if you restart ComboTracker.
- Only these keys are ever sent to the page, so other typing stays private.

## Characters: moves and team rotations

The **Characters** page (in the sidebar, or `http://localhost:8737/characters.html`) shows what each Wuthering Waves character's buttons do. It covers inputs only: no damage numbers.

- **Moves**: the Basic Attack chain (A1, A2, ...), what holding LMB does, which hit LMB lands on after the Intro, Skill, Liberation, a Heavy, a Dodge Counter or a Mid-air Attack, and each move with its key. The timeline's move names use the same data.
- **Team rotations**: the community's team setups for that character with author and video, from [AntoCrasher's rotation compilation](https://docs.google.com/spreadsheets/d/1mdl9J08N-0_j-U2zNP5OTHGBKprwmJEmz_Iy4IOJfPk/edit). **Combo** opens the move-by-move rotation transcript, converts it to tracker inputs (swaps use the team's slot order; no timings), and **Save as combo** adds it to your combos with the video attached.

The move data ships with the app in `static/data/ww_characters.json`. It was drafted from the [encore.moe](https://encore.moe) API and checked by hand for the characters marked "Checked by hand"; edit that file to fix or add details. **Download raw data** saves encore.moe's unedited files to `data/encore_raw/` without changing anything shown; `python tools/ww_build_moves.py` then drafts any new characters from them (hand-checked entries are left alone). Rotations are downloaded when you open them and cached in `ww_library_cache/`.

---

## Documentation
Expand Down
Loading
Loading