diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38ffb91..70cd7a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: with: python-version: "3.11" - name: Install dependencies - run: python -m pip install -r requirements.txt -r requirements-build.txt + run: python -m pip install -r requirements.txt - name: Build run: python -m PyInstaller --noconfirm --clean ComboTracker.spec - uses: actions/upload-artifact@v4 diff --git a/Game_Wuthering_Waves.py b/Game_Wuthering_Waves.py index 18f0a19..9c3c859 100644 --- a/Game_Wuthering_Waves.py +++ b/Game_Wuthering_Waves.py @@ -43,6 +43,10 @@ class WutheringWavesGame: # Global dash / RMB icon (shared across all teams) ww_dash_image: str = "" + # Your own notes per character (Characters page), keyed by the character id in + # static/data/ww_characters.json. They replace the shipped notes for that character. + ww_character_notes: dict[str, str] = field(default_factory=dict) + # Active character slot during an attempt ("1", "2", or "3"). ww_active_character: str | None = None @@ -205,6 +209,7 @@ def editor_payload(self, combo_name: str) -> dict[str, Any]: "ww_team_lmb_images": team_lmb_images, "ww_team_ability_images": team_ability_images, "ww_characters": ww_chars_list, + "ww_character_notes": dict(self.ww_character_notes), } # ------------------------- @@ -391,6 +396,21 @@ def delete_ww_character_cmd(engine, *, name: str) -> tuple[bool, str | None]: return True, None +def save_ww_character_note_cmd(engine, *, char_id: str, text: str) -> tuple[bool, str | None]: + cid = str(char_id or "").strip() + if not cid or len(cid) > 32: + return False, "Unknown character." + note = str(text or "").replace("\r\n", "\n").strip() + if len(note) > 20000: + return False, "That note is too long to save." + if engine.ww.ww_character_notes.get(cid) == note: + return True, None + engine.ww.ww_character_notes[cid] = note + engine.save_combos() + engine._send({"type": "ww_character_notes", "notes": dict(engine.ww.ww_character_notes)}) + return True, None + + def update_ww_dash_cmd(engine, *, dash_image: str) -> tuple[bool, str | None]: engine.ww.ww_dash_image = str(dash_image or "").strip() engine.save_combos() diff --git a/README.md b/README.md index b51eb64..4b19302 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,9 @@ Notes: ### Building the Windows release You can package ComboTracker so others can run it without installing Python. -1. Install build tooling (once): +1. Install the requirements, which include PyInstaller (once): ```bash - python -m pip install -r requirements-build.txt + python -m pip install -r requirements.txt ``` 2. From the project root, build: ```bash @@ -127,9 +127,10 @@ A built-in, NohBoard-style key display for streams and recordings: WASD, Shift, 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. +- **Timings**: each ability's frame data from [WuwaLAB](https://wuwalab.com), laid out like WuwaLAB's Abilities tab: hits, total frames, the earliest cancel frame, how long until you can swap, time stop, motion stop, cooldown and the frame each hit lands on. Switch between frames and seconds (60 fps), and filter by name. - **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/`. +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). The timings ship in `static/data/ww_timings.json`; to update them, scrape WuwaLAB's ability pages into one JSON file and run `python tools/ww_import_timings.py that-file.json` (damage columns are dropped). Rotations are downloaded when you open them and cached in `ww_library_cache/`. --- diff --git a/_combo_commands.py b/_combo_commands.py index 98e47e6..e65807f 100644 --- a/_combo_commands.py +++ b/_combo_commands.py @@ -1,243 +1,243 @@ -""" -UI command handlers: apply/save/delete combo, new combo, clear history. -Internal module: call only from ComboTrackerEngine while holding the engine lock. -""" - -from __future__ import annotations - -from typing import Any - -import combo_engine_ui as ui -import format_utils -import stats_recording -from parser import split_inputs -from persistence import fresh_combo_stats - - -def per_combo_maps(engine) -> list[dict[str, Any]]: - """Every dict keyed by combo name. Rename and delete go through this list so none is missed.""" - return [ - engine.combo_stats, - engine.combo_expected_ms, - engine.combo_user_difficulty, - engine.combo_step_display_mode, - engine.combo_key_images, - engine.combo_demo_video, - engine.ww.combo_ww_team, - ] - - -def apply_enders_from_text(engine, raw: str) -> tuple[bool, str | None]: - raw = (raw or "").strip() - if not raw: - engine.combo_enders = {} - setattr(engine, "combo_enders_soft", set()) - engine.save_combos() - return True, None - - parsed: dict[str, int] = {} - soft_keys: set[str] = set() - for token in split_inputs(raw): - t = token.strip() - if not t: - continue - if ":" in t: - k, v = t.split(":", 1) - raw_key = k.strip().lower() - # ~key:2s = soft ender (does not drop combo when pressed during hold) - if raw_key.startswith("~"): - raw_key = raw_key[1:].strip().lower() - if raw_key: - soft_keys.add(raw_key) - key = raw_key - val = (v or "").strip().lower() - if not key: - continue - # Require explicit "s" suffix for seconds: key:2s, key:0.2s - if val.endswith("s"): - val = val[:-1].strip() - else: - return False, f"Ender cooldown must be in seconds with 's' suffix, e.g. {key}:2s or {key}:0.2s" - try: - sec = float(val) - except ValueError: - return False, f"Invalid timing for '{key}'. Use seconds with 's', e.g. {key}:2s" - parsed[key] = max(0, int(sec * 1000)) - else: - key = t.strip().lower() - if key.startswith("~"): - key = key[1:].strip().lower() - if key: - soft_keys.add(key) - if key: - parsed[key] = 0 - - engine.combo_enders = parsed - engine.combo_enders_soft = soft_keys - engine.save_combos() - return True, None - - -def save_or_update_combo( - engine, - *, - name: str, - inputs: str, - enders: str | None, - expected_time: str | None = None, - user_difficulty: str | None = None, - step_display_mode: str | None = None, - key_images: Any | None = None, - demo_video: str | None = None, - ww_team_id: str | None = None, - as_new: bool = False, -) -> tuple[bool, str | None]: - name = (name or "").strip() - keys_str = (inputs or "").strip() - if not name or not keys_str: - return False, "Please fill in Name and Inputs." - - # Enders are global settings; a caller that doesn't send them (the Characters page) leaves them alone. - if enders is not None: - ok, err = apply_enders_from_text(engine, enders) - if not ok: - return False, err - - expected_ms = None - expected_raw = (expected_time or "").strip() - if expected_raw: - expected_ms = format_utils.parse_expected_time_ms(expected_raw) - if expected_ms is None: - return False, "Invalid Expected time. Examples: 1.05s or 1050ms" - - user_diff_val = None - ud_raw = (user_difficulty or "").strip() - if ud_raw: - try: - user_diff_val = float(ud_raw) - except Exception: - return False, "Invalid Your difficulty. Use a number from 0 to 10." - if not (0.0 <= user_diff_val <= 10.0): - return False, "Invalid Your difficulty. Use a number from 0 to 10." - - input_list = [k.strip().lower() for k in split_inputs(keys_str) if k.strip()] - if not input_list: - return False, "Please provide at least one input." - - # as_new: save a separate combo instead of editing (and renaming) the active one. - old_name = None if as_new else (engine.active_combo_name if engine.active_combo_name in engine.combos else None) - if name != old_name and name in engine.combos: - return False, f"A combo named '{name}' already exists. Pick another name." - if old_name and name != old_name: - # Rename: carry everything stored under the old name (stats included) to the new one. - del engine.combos[old_name] - engine.combos[name] = input_list - for mapping in per_combo_maps(engine): - if old_name in mapping: - mapping[name] = mapping.pop(old_name) - else: - # Same name or new combo: if steps changed, clear all history for this combo - old_list = engine.combos.get(name) - if old_list is not None and old_list != input_list: - engine.combo_stats[name] = fresh_combo_stats() - engine.combos[name] = input_list - - if expected_ms is not None: - engine.combo_expected_ms[name] = int(expected_ms) - else: - engine.combo_expected_ms.pop(name, None) - - if user_diff_val is not None: - engine.combo_user_difficulty[name] = float(user_diff_val) - else: - engine.combo_user_difficulty.pop(name, None) - - mode_raw = (step_display_mode or "").strip().lower() - if mode_raw in ("icons", "images"): - engine.combo_step_display_mode[name] = mode_raw - else: - engine.combo_step_display_mode.pop(name, None) - - cleaned_imgs: dict[str, str] = {} - if isinstance(key_images, dict): - for k, v in key_images.items(): - key = str(k).strip().lower() - url = str(v).strip() - if not key or not url: - continue - cleaned_imgs[key] = url - if cleaned_imgs: - engine.combo_key_images[name] = cleaned_imgs - else: - if isinstance(key_images, dict): - engine.combo_key_images.pop(name, None) - - demo_url = (demo_video or "").strip() - if demo_url: - engine.combo_demo_video[name] = demo_url - else: - engine.combo_demo_video.pop(name, None) - - engine.ww.apply_combo_team_assignment(name, ww_team_id=ww_team_id) - - stats_recording.ensure_combo_stats(engine, name) - engine.set_active_combo(name, emit=False) - engine.save_combos() - - engine._send({"type": "init", **engine.init_payload()}) - return True, None - - -def delete_combo(engine, name: str) -> tuple[bool, str | None]: - name = (name or "").strip() - if not name or name not in engine.combos: - return False, "Select a combo to delete." - - del engine.combos[name] - for mapping in per_combo_maps(engine): - mapping.pop(name, None) - - if engine.active_combo_name == name: - engine.active_combo_name = None - engine.active_combo_tokens = [] - engine.runtime_steps = [] - engine.reset_tracking() - - engine.save_combos() - engine._send({"type": "init", **engine.init_payload()}) - return True, None - - -def new_combo(engine) -> None: - engine.active_combo_name = None - engine.active_combo_tokens = [] - engine.runtime_steps = [] - engine.reset_tracking() - engine._send({"type": "init", **engine.init_payload()}) - - -def clear_history_and_stats(engine) -> None: - engine.reset_tracking() - if engine.active_combo_name: - engine.combo_stats[engine.active_combo_name] = fresh_combo_stats() - engine.save_combos() - engine._send({"type": "clear_results"}) - engine._send({"type": "stat_update", "stats": ui.stats_text(engine)}) - engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) - engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) - st = engine.get_status() - engine._send({"type": "status", "text": st.text, "color": st.color}) - - -def clear_all_history_and_stats(engine) -> None: - """Wipe history and stats for every combo.""" - engine.reset_tracking() - for name in list(engine.combos.keys()): - engine.combo_stats[name] = fresh_combo_stats() - engine.save_combos() - engine._send({"type": "clear_results"}) - engine._send({"type": "stat_update", "stats": ui.stats_text(engine)}) - engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) - engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) - st = engine.get_status() - engine._send({"type": "status", "text": st.text, "color": st.color}) +""" +UI command handlers: apply/save/delete combo, new combo, clear history. +Internal module: call only from ComboTrackerEngine while holding the engine lock. +""" + +from __future__ import annotations + +from typing import Any + +import combo_engine_ui as ui +import format_utils +import stats_recording +from parser import lower_outside_quotes, split_inputs +from persistence import fresh_combo_stats + + +def per_combo_maps(engine) -> list[dict[str, Any]]: + """Every dict keyed by combo name. Rename and delete go through this list so none is missed.""" + return [ + engine.combo_stats, + engine.combo_expected_ms, + engine.combo_user_difficulty, + engine.combo_step_display_mode, + engine.combo_key_images, + engine.combo_demo_video, + engine.ww.combo_ww_team, + ] + + +def apply_enders_from_text(engine, raw: str) -> tuple[bool, str | None]: + raw = (raw or "").strip() + if not raw: + engine.combo_enders = {} + setattr(engine, "combo_enders_soft", set()) + engine.save_combos() + return True, None + + parsed: dict[str, int] = {} + soft_keys: set[str] = set() + for token in split_inputs(raw): + t = token.strip() + if not t: + continue + if ":" in t: + k, v = t.split(":", 1) + raw_key = k.strip().lower() + # ~key:2s = soft ender (does not drop combo when pressed during hold) + if raw_key.startswith("~"): + raw_key = raw_key[1:].strip().lower() + if raw_key: + soft_keys.add(raw_key) + key = raw_key + val = (v or "").strip().lower() + if not key: + continue + # Require explicit "s" suffix for seconds: key:2s, key:0.2s + if val.endswith("s"): + val = val[:-1].strip() + else: + return False, f"Ender cooldown must be in seconds with 's' suffix, e.g. {key}:2s or {key}:0.2s" + try: + sec = float(val) + except ValueError: + return False, f"Invalid timing for '{key}'. Use seconds with 's', e.g. {key}:2s" + parsed[key] = max(0, int(sec * 1000)) + else: + key = t.strip().lower() + if key.startswith("~"): + key = key[1:].strip().lower() + if key: + soft_keys.add(key) + if key: + parsed[key] = 0 + + engine.combo_enders = parsed + engine.combo_enders_soft = soft_keys + engine.save_combos() + return True, None + + +def save_or_update_combo( + engine, + *, + name: str, + inputs: str, + enders: str | None, + expected_time: str | None = None, + user_difficulty: str | None = None, + step_display_mode: str | None = None, + key_images: Any | None = None, + demo_video: str | None = None, + ww_team_id: str | None = None, + as_new: bool = False, +) -> tuple[bool, str | None]: + name = (name or "").strip() + keys_str = (inputs or "").strip() + if not name or not keys_str: + return False, "Please fill in Name and Inputs." + + # Enders are global settings; a caller that doesn't send them (the Characters page) leaves them alone. + if enders is not None: + ok, err = apply_enders_from_text(engine, enders) + if not ok: + return False, err + + expected_ms = None + expected_raw = (expected_time or "").strip() + if expected_raw: + expected_ms = format_utils.parse_expected_time_ms(expected_raw) + if expected_ms is None: + return False, "Invalid Expected time. Examples: 1.05s or 1050ms" + + user_diff_val = None + ud_raw = (user_difficulty or "").strip() + if ud_raw: + try: + user_diff_val = float(ud_raw) + except Exception: + return False, "Invalid Your difficulty. Use a number from 0 to 10." + if not (0.0 <= user_diff_val <= 10.0): + return False, "Invalid Your difficulty. Use a number from 0 to 10." + + input_list = [lower_outside_quotes(k.strip()) for k in split_inputs(keys_str) if k.strip()] + if not input_list: + return False, "Please provide at least one input." + + # as_new: save a separate combo instead of editing (and renaming) the active one. + old_name = None if as_new else (engine.active_combo_name if engine.active_combo_name in engine.combos else None) + if name != old_name and name in engine.combos: + return False, f"A combo named '{name}' already exists. Pick another name." + if old_name and name != old_name: + # Rename: carry everything stored under the old name (stats included) to the new one. + del engine.combos[old_name] + engine.combos[name] = input_list + for mapping in per_combo_maps(engine): + if old_name in mapping: + mapping[name] = mapping.pop(old_name) + else: + # Same name or new combo: if steps changed, clear all history for this combo + old_list = engine.combos.get(name) + if old_list is not None and old_list != input_list: + engine.combo_stats[name] = fresh_combo_stats() + engine.combos[name] = input_list + + if expected_ms is not None: + engine.combo_expected_ms[name] = int(expected_ms) + else: + engine.combo_expected_ms.pop(name, None) + + if user_diff_val is not None: + engine.combo_user_difficulty[name] = float(user_diff_val) + else: + engine.combo_user_difficulty.pop(name, None) + + mode_raw = (step_display_mode or "").strip().lower() + if mode_raw in ("icons", "images"): + engine.combo_step_display_mode[name] = mode_raw + else: + engine.combo_step_display_mode.pop(name, None) + + cleaned_imgs: dict[str, str] = {} + if isinstance(key_images, dict): + for k, v in key_images.items(): + key = str(k).strip().lower() + url = str(v).strip() + if not key or not url: + continue + cleaned_imgs[key] = url + if cleaned_imgs: + engine.combo_key_images[name] = cleaned_imgs + else: + if isinstance(key_images, dict): + engine.combo_key_images.pop(name, None) + + demo_url = (demo_video or "").strip() + if demo_url: + engine.combo_demo_video[name] = demo_url + else: + engine.combo_demo_video.pop(name, None) + + engine.ww.apply_combo_team_assignment(name, ww_team_id=ww_team_id) + + stats_recording.ensure_combo_stats(engine, name) + engine.set_active_combo(name, emit=False) + engine.save_combos() + + engine._send({"type": "init", **engine.init_payload()}) + return True, None + + +def delete_combo(engine, name: str) -> tuple[bool, str | None]: + name = (name or "").strip() + if not name or name not in engine.combos: + return False, "Select a combo to delete." + + del engine.combos[name] + for mapping in per_combo_maps(engine): + mapping.pop(name, None) + + if engine.active_combo_name == name: + engine.active_combo_name = None + engine.active_combo_tokens = [] + engine.runtime_steps = [] + engine.reset_tracking() + + engine.save_combos() + engine._send({"type": "init", **engine.init_payload()}) + return True, None + + +def new_combo(engine) -> None: + engine.active_combo_name = None + engine.active_combo_tokens = [] + engine.runtime_steps = [] + engine.reset_tracking() + engine._send({"type": "init", **engine.init_payload()}) + + +def clear_history_and_stats(engine) -> None: + engine.reset_tracking() + if engine.active_combo_name: + engine.combo_stats[engine.active_combo_name] = fresh_combo_stats() + engine.save_combos() + engine._send({"type": "clear_results"}) + engine._send({"type": "stat_update", "stats": ui.stats_text(engine)}) + engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) + engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) + st = engine.get_status() + engine._send({"type": "status", "text": st.text, "color": st.color}) + + +def clear_all_history_and_stats(engine) -> None: + """Wipe history and stats for every combo.""" + engine.reset_tracking() + for name in list(engine.combos.keys()): + engine.combo_stats[name] = fresh_combo_stats() + engine.save_combos() + engine._send({"type": "clear_results"}) + engine._send({"type": "stat_update", "stats": ui.stats_text(engine)}) + engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) + engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) + st = engine.get_status() + engine._send({"type": "status", "text": st.text, "color": st.color}) diff --git a/combo_engine.py b/combo_engine.py index 4c1af67..7eef71c 100644 --- a/combo_engine.py +++ b/combo_engine.py @@ -17,6 +17,7 @@ save_ww_character_cmd, delete_ww_character_cmd, update_ww_dash_cmd, + save_ww_character_note_cmd, ) import combo_analytics import combo_engine_ui as ui @@ -437,6 +438,10 @@ def delete_ww_character(self, name: str) -> tuple[bool, str | None]: with self._lock: return delete_ww_character_cmd(self, name=name) + def save_ww_character_note(self, char_id: str, text: str) -> tuple[bool, str | None]: + with self._lock: + return save_ww_character_note_cmd(self, char_id=char_id, text=text) + def update_ww_dash(self, dash_image: str) -> tuple[bool, str | None]: with self._lock: return update_ww_dash_cmd(self, dash_image=dash_image) diff --git a/combo_engine_ui.py b/combo_engine_ui.py index 9487195..bcb9d6f 100644 --- a/combo_engine_ui.py +++ b/combo_engine_ui.py @@ -322,6 +322,8 @@ def _render_sequence_items( prog = _wait_progress(nxt, now) if prog is not None: pw["progress"] = prog + if nxt.optional: + pw["wait_optional"] = True seq_items.append(pw) j += 2 continue @@ -341,6 +343,8 @@ def _render_sequence_items( prog = _wait_progress(sub, now) if prog is not None: wait_dict["progress"] = prog + if sub.optional: + wait_dict["optional"] = True seq_items.append(wait_dict) elif isinstance(sub, HoldState): seq_items.append( @@ -482,13 +486,16 @@ def _timeline_steps_from_runtime(engine, now: float | None = None) -> TimelineSt if len(seq.steps) >= 2 and isinstance(seq.steps[1], WaitState): dur = seq.steps[1].required_ms pw_active = step.active_item is item - items_payload.append({ + pw_item = { "type": "press_wait", "input": inp, "duration": dur, "active": (idx == cur) and pw_active, "completed": comp, - }) + } + if len(seq.steps) >= 2 and isinstance(seq.steps[1], WaitState) and seq.steps[1].optional: + pw_item["wait_optional"] = True + items_payload.append(pw_item) elif item.kind == "sequence" and isinstance(item.state, SequenceState): seq = item.state parent_is_active = bool((idx == cur) and (step.active_item is item)) @@ -545,6 +552,8 @@ def _timeline_steps_from_runtime(engine, now: float | None = None) -> TimelineSt if getattr(step, "optional", False): pw["optional"] = True pw["was_skipped"] = getattr(step, "was_skipped", False) + if nxt.optional: + pw["wait_optional"] = True steps.append(pw) i += 1 continue @@ -635,6 +644,8 @@ def _timeline_steps_from_runtime(engine, now: float | None = None) -> TimelineSt prog = _wait_progress(step, now) if prog is not None: w["progress"] = prog + if step.optional: + w["optional"] = True steps.append(w) i += 1 continue diff --git a/documentation.md b/documentation.md index f992e1a..7cc4199 100644 --- a/documentation.md +++ b/documentation.md @@ -41,6 +41,14 @@ Prefix a key with `-` to make it optional: `-e` means you may skip that key. If - Example: `f, wait:0.3s, -e, wait:0.80s, r, wait:1.8s` — you can do `f`, wait, then `r` (skipping `e`) or `f`, wait, `e`, wait, `r`. In the timeline, optional steps use a dashed border; skipped ones show a subtle “skipped” style, pressed ones show “was-pressed”. +### Picking which move a step is + +Some keys can mean more than one move, and the keys alone can't tell which: LMB after a dodge is either a Dodge Counter or Basic 1, a held LMB can be Heavy 1 or Heavy 2, and so on. Right-click the step on the timeline and pick the move from the list (the moves that key can cast for that character, from WuwaLAB, best guess first). The list leaves out moves the step before rules out (after a dodge, LMB is the Dodge Counter or Basic 1; after a jump, only Mid-Air moves). The pick is saved in the inputs as a quoted name after the key, e.g. `rmb, lmb "Basic: Origin Calculus 1", wait:0.15s`, and you can type it by hand too. It only changes the move name on the timeline, the Basic chain count after it and the Concerto estimate; the tracker ignores it when checking your presses. For characters WuwaLAB covers, every step is named with WuwaLAB's name for the move: the best guess until you pick another. The move in use is always checked; picking the best guess again removes the saved name. Names can't contain double quotes. + +### Optional waits + +Prefix a wait with `-` to make it optional: `-wait:0.15s`. Pressing any key during that wait ends it early and the press counts for the next step, so you can either wait it out or go straight on. Like `wait:`, right after a key it joins that key's tile (`e, -wait:0.15s, r`: press `e`, then `r` whenever you like), and elsewhere it is its own tile (`hold(lmb, 0.5s), -wait:0.2s, e`). Plain `wait:` is unchanged. In the timeline, an optional wait has a dashed right edge. + ### Any-order groups (order interchangeable) Use brackets to indicate that multiple inputs can be pressed in **any order**, but all must be completed before the combo continues: diff --git a/parser.py b/parser.py index 75d4e0e..adb71ab 100644 --- a/parser.py +++ b/parser.py @@ -5,6 +5,7 @@ AST to dict format for the existing engine (compatibility layer). """ +import re from dataclasses import dataclass from typing import Any, Literal from uuid import uuid4 @@ -50,6 +51,7 @@ class WaitNode: duration_ms: int mode: Literal["soft", "hard", "mandatory"] wait_for: str | None = None # for mandatory waits + optional: bool = False # -wait:Xs: any key press during the wait ends it early (no fail) @dataclass(frozen=True) @@ -80,14 +82,22 @@ def split_inputs(keys_str: str) -> list[str]: """ Split a user-entered Inputs string into top-level comma-separated tokens. - Shallow parser: avoids splitting commas inside (), {}, []. + Shallow parser: avoids splitting commas inside (), {}, [] and "move names". """ s = keys_str or "" out: list[str] = [] buf: list[str] = [] paren = brace = bracket = 0 + quoted = False for ch in s: + if ch == '"': + quoted = not quoted + buf.append(ch) + continue + if quoted: + buf.append(ch) + continue if ch == "(": paren += 1 elif ch == ")": @@ -115,6 +125,26 @@ def split_inputs(keys_str: str) -> list[str]: return out +# A step can name the move it casts: lmb "Basic: Origin Calculus 2 (Dodge Counter)". The name is +# for the timeline label only (picked from the step's right-click menu); the engine ignores it. +_MOVE_NAME_RE = re.compile(r'\s*"([^"]*)"\s*$') + + +def split_move_name(token: str) -> tuple[str, str | None]: + """'lmb "Basic: X 1"' -> ('lmb', 'Basic: X 1'); a token without a name -> (token, None).""" + t = token or "" + m = _MOVE_NAME_RE.search(t) + if not m: + return t, None + return t[: m.start()], m.group(1) + + +def lower_outside_quotes(token: str) -> str: + """Lowercase the keys of a token but keep a "Move Name" as written.""" + parts = (token or "").split('"') + return '"'.join(p.lower() if i % 2 == 0 else p for i, p in enumerate(parts)) + + def _parse_duration(raw: str) -> int | None: token = (raw or "").lower().strip() if not token: @@ -150,7 +180,7 @@ def parse_step(token: str) -> StepNode | None: Returns None for empty/invalid tokens. """ - t = (token or "").strip() + t = split_move_name((token or "").strip())[0].strip() if not t: return None @@ -273,11 +303,13 @@ def parse_step(token: str) -> StepNode | None: return GroupNode(tuple(items)) # Wait gate: wait:0.1 (only form; use after a key e.g. f, wait:0.23s) - if tl.startswith("wait:"): - dur = tl[len("wait:"):].strip() + # Optional wait: -wait:0.15s, which the next press may cut short. + optional_wait = tl.startswith("-wait:") + if tl.startswith("wait:") or optional_wait: + dur = tl[tl.index(":") + 1:].strip() wait_ms = _parse_duration(dur) if wait_ms is not None: - return WaitNode(wait_ms, "soft", None) + return WaitNode(wait_ms, "soft", None, optional=optional_wait) # Repeated taps over a duration: spam(lmb, 4.4s) if tl.startswith("spam(") and tl.endswith(")"): diff --git a/persistence.py b/persistence.py index 7fb33da..2580a5d 100644 --- a/persistence.py +++ b/persistence.py @@ -465,6 +465,10 @@ def load_engine_state(engine) -> None: _load_combo_options(engine, data) _load_ww_characters(engine, data) _load_ww_teams(engine, data) + notes = data.get("ww_character_notes") + engine.ww.ww_character_notes = { + str(k): str(v) for k, v in notes.items() if isinstance(v, str) + } if isinstance(notes, dict) else {} _migrate_legacy_combo_ability_images(engine, data) _load_macro_settings(engine, data) @@ -485,6 +489,7 @@ def load_engine_state(engine) -> None: engine.combo_demo_video = {} engine.ww.ww_characters = {} engine.ww.ww_dash_image = "" + engine.ww.ww_character_notes = {} engine.ww.ww_teams = {} engine.ww.ww_active_team_id = None engine.ww.combo_ww_team = {} @@ -525,6 +530,7 @@ def save_engine_state(engine) -> bool: "combo_demo_video": dict(getattr(engine, "combo_demo_video", {})), "ww_characters": dict(engine.ww.ww_characters), "ww_dash_image": engine.ww.ww_dash_image, + "ww_character_notes": dict(getattr(engine.ww, "ww_character_notes", {})), "ww_teams": { tid: { "name": tv.get("name", "Team"), diff --git a/states.py b/states.py index de59a9c..8259943 100644 --- a/states.py +++ b/states.py @@ -162,6 +162,7 @@ def to_dict(self) -> dict: "type": "spam", "input": self.expected, "duration": self.required_ms, + "optional": self.optional, "in_progress": self.in_progress, "completed": self.completed, } @@ -373,6 +374,7 @@ class WaitState: required_ms: int mode: Literal["soft", "hard", "mandatory"] wait_for: str | None = None # for mandatory waits + optional: bool = False # -wait:Xs: a press during the wait ends it and counts for the next step # Runtime mutable state in_progress: bool = False @@ -400,6 +402,12 @@ def process_press(self, key: str, now: float) -> ProcessResult: if self.mode == "mandatory": return IgnoreResult() + # Optional wait: the press ends it early and is re-processed against the next step. + if self.optional: + self.completed = True + self.in_progress = False + return CompleteResult() + if self.mode == "hard": return FailResult("early press during hard wait") @@ -788,7 +796,7 @@ def build_runtime_state(node: StepNode) -> StepState: return SpamState(expected=key, required_ms=ms) case WaitNode(duration_ms=ms, mode=mode, wait_for=wf): - return WaitState(required_ms=ms, mode=mode, wait_for=wf) + return WaitState(required_ms=ms, mode=mode, wait_for=wf, optional=getattr(node, "optional", False)) case SequenceNode(steps=steps): is_mandatory = _is_composite_mandatory(node) diff --git a/static/app/combos.js b/static/app/combos.js index 18cccf7..db1e1b9 100644 --- a/static/app/combos.js +++ b/static/app/combos.js @@ -15,7 +15,6 @@ function initializeUI(data) { wwTeamId: appState.wwTeamId, stepDisplayMode: appState.stepDisplayMode, noFailMode: !!getEl('noFailMode')?.checked, - stepEditMode: !!getEl('stepEditToggle')?.checked, collapseChainedPresses: !!getEl('collapseChainsToggle')?.checked, keyImages: { ...appState.keyImages }, } : null; @@ -31,9 +30,6 @@ function initializeUI(data) { if (stepToggle) stepToggle.checked = (appState.stepDisplayMode === 'images'); const noFailEl = getEl('noFailMode'); if (noFailEl) noFailEl.checked = preserved.noFailMode; - appState.stepEditMode = preserved.stepEditMode; - const stepEditToggle = getEl('stepEditToggle'); - if (stepEditToggle) stepEditToggle.checked = preserved.stepEditMode; appState.collapseChainedPresses = preserved.collapseChainedPresses; const collapseChainsToggle = getEl('collapseChainsToggle'); if (collapseChainsToggle) collapseChainsToggle.checked = preserved.collapseChainedPresses; @@ -222,10 +218,10 @@ document.addEventListener('keydown', (e) => { } }); -// Ctrl/Cmd+Z: undo the last Edit Steps mutation (delete, reorder, or inline field edit) +// Ctrl/Cmd+Z: undo the last step edit (delete, reorder, or inline field edit) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - if (!appState.stepEditMode || appState.editStepsUndoStack.length === 0) return; + if (appState.editStepsUndoStack.length === 0) return; e.preventDefault(); undoLastEditStep(); } @@ -407,10 +403,10 @@ function tokenizeComboInput(text) { tokens.push({ type: 'hold', text: text.slice(start, i) }); continue; } - // wait:duration (soft wait) - if (text.slice(i).match(/^wait\s*:/)) { + // wait:duration (soft wait), -wait:duration (optional wait) + if (text.slice(i).match(/^-?wait\s*:/)) { const start = i; - i += text.slice(i).match(/^wait\s*:/)[0].length; + i += text.slice(i).match(/^-?wait\s*:/)[0].length; while (i < len && /[^\s,\[\]\{\}]/.test(text[i])) i++; tokens.push({ type: 'wait', text: text.slice(start, i) }); continue; @@ -423,6 +419,14 @@ function tokenizeComboInput(text) { tokens.push({ type: 'optional', text: text.slice(start, i) }); continue; } + // "Move Name" picked from a step's right-click menu + if (text[i] === '"') { + const close = text.indexOf('"', i + 1); + const end = close === -1 ? len : close + 1; + tokens.push({ type: 'name', text: text.slice(i, end) }); + i = end; + continue; + } // brackets and braces if (text[i] === '[' || text[i] === ']') { tokens.push({ type: 'bracket', text: text[i] }); @@ -451,12 +455,24 @@ function tokenizeComboInput(text) { }).join(''); } +// [start, end) of the Inputs text to mark, for the timeline tile being dragged or selected +// (see "Tile focus" below), or null. +let comboInputMark = null; + function updateComboInputHighlight() { const ta = getEl('comboInputs'); const mirror = getEl('comboInputHighlight'); if (!ta || !mirror) return; const raw = (ta.value || ''); - mirror.innerHTML = raw ? tokenizeComboInput(raw) : ''; + const m = comboInputMark; + if (raw && m && m.text === raw && m.start < m.end) { + // Marks fall on top-level token boundaries, so each slice tokenizes the same as the whole. + mirror.innerHTML = tokenizeComboInput(raw.slice(0, m.start)) + + `${tokenizeComboInput(raw.slice(m.start, m.end))}` + + tokenizeComboInput(raw.slice(m.end)); + } else { + mirror.innerHTML = raw ? tokenizeComboInput(raw) : ''; + } mirror.scrollTop = ta.scrollTop; mirror.scrollLeft = ta.scrollLeft; } @@ -486,6 +502,7 @@ function splitInputsTokenSpans(str) { const spans = []; let depth = 0; let start = 0; + let quoted = false; // inside a "Move Name" const push = (end) => { const raw = str.slice(start, end); const lead = raw.length - raw.trimStart().length; @@ -494,6 +511,8 @@ function splitInputsTokenSpans(str) { }; for (let i = 0; i < str.length; i++) { const ch = str[i]; + if (ch === '"') { quoted = !quoted; continue; } + if (quoted) continue; if (ch === '(' || ch === '{' || ch === '[') depth++; else if (ch === ')' || ch === '}' || ch === ']') depth = Math.max(0, depth - 1); else if (ch === ',' && depth === 0) { push(i); start = i + 1; } @@ -545,3 +564,141 @@ if (inputsEl) { const timeline = getEl('comboTimeline'); if (timeline) new MutationObserver(() => { if (document.activeElement === inputsEl) applyEditFocus(); }).observe(timeline, { childList: true }); } + +// --------------------------------------------------------------------------- +// Tile focus: the reverse of editing focus. Dragging a Combo Steps tile, or clicking one, marks +// its text in the Inputs box. Like editing focus it only follows tokens that still line up +// with the saved combo. +// --------------------------------------------------------------------------- + +/** [start, end) of the Inputs text behind these runtime steps, or null when it can't be matched. */ +function inputRangeForRuntime(runtimeIndices) { + const ta = getEl('comboInputs'); + if (!ta || !runtimeIndices.length) return null; + const saved = splitInputsTokens(appState.savedInputs || ''); + const srcMap = buildRuntimeToSourceMap(saved); + const tokIdx = new Set(); + runtimeIndices.forEach((r) => (srcMap[r] || []).forEach((t) => tokIdx.add(t))); + if (!tokIdx.size) return null; + const spans = splitInputsTokenSpans(ta.value || ''); + const last = Math.max(...tokIdx); + for (let i = 0; i <= last; i++) { + if (!spans[i] || (saved[i] || '').toLowerCase() !== spans[i].text.toLowerCase()) return null; + } + const first = Math.min(...tokIdx); + return { start: spans[first].start, end: spans[last].end, text: ta.value || '' }; +} + +function setInputMarkForTile(tile) { + const raw = tile ? (tile.dataset.stepIndices || '').trim() : ''; + const indices = raw ? raw.split(',').map((v) => Number.parseInt(v, 10)).filter(Number.isFinite) : []; + comboInputMark = inputRangeForRuntime(indices); + updateComboInputHighlight(); + if (!comboInputMark) return; + // Bring the marked text into view inside the Inputs box. + const ta = getEl('comboInputs'); + const mark = getEl('comboInputHighlight')?.querySelector('.combo-hl-focus'); + if (!ta || !mark) return; + const top = mark.offsetTop; + if (top < ta.scrollTop || top + mark.offsetHeight > ta.scrollTop + ta.clientHeight) { + ta.scrollTop = Math.max(0, top - ta.clientHeight / 3); + updateComboInputHighlight(); + } +} + +{ + const timeline = getEl('comboTimeline'); + const topTile = (target) => (timeline && target ? [...timeline.children].find((c) => c.contains(target)) : null); + if (timeline) { + timeline.addEventListener('dragstart', (ev) => setInputMarkForTile(topTile(ev.target))); + timeline.addEventListener('dragend', () => { comboInputMark = null; updateComboInputHighlight(); }); + timeline.addEventListener('click', (ev) => { + if (ev.target.closest('button, input, [contenteditable="true"]')) return; + const tile = topTile(ev.target); + timeline.querySelectorAll('.step-selected').forEach((el) => el.classList.remove('step-selected')); + if (tile && tile.dataset.stepIndices) tile.classList.add('step-selected'); + setInputMarkForTile(tile); + }); + } + // Clicking anywhere else clears the mark; typing in the Inputs box drops it (offsets move). + document.addEventListener('click', (ev) => { + if (!comboInputMark || (timeline && timeline.contains(ev.target))) return; + if (ev.target.closest && ev.target.closest('#comboInputs')) return; + comboInputMark = null; + timeline?.querySelectorAll('.step-selected').forEach((el) => el.classList.remove('step-selected')); + updateComboInputHighlight(); + }); + getEl('comboInputs')?.addEventListener('input', () => { comboInputMark = null; }); +} + +// --------------------------------------------------------------------------- +// Copy and paste tiles: Ctrl+C copies the selected tile's inputs, Ctrl+V pastes them next to +// the tile under (or nearest) the mouse: before it on its left half, after it on its right. +// Text boxes keep their own copy and paste. +// --------------------------------------------------------------------------- + +let tileClipboard = null; // input tokens of the copied tile +const mousePos = { x: -1, y: -1 }; +document.addEventListener('mousemove', (ev) => { mousePos.x = ev.clientX; mousePos.y = ev.clientY; }, { passive: true }); + +/** Source token indices (in the Inputs box) behind a timeline tile, sorted, or []. */ +function tokenIndicesForTile(tile, tokens) { + const raw = tile ? (tile.dataset.stepIndices || '').trim() : ''; + if (!raw) return []; + const srcMap = buildRuntimeToSourceMap(tokens); + const out = new Set(); + raw.split(',').forEach((v) => (srcMap[Number.parseInt(v, 10)] || []).forEach((t) => out.add(t))); + return [...out].sort((a, b) => a - b); +} + +function isTypingTarget(el) { + return !!(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))); +} + +document.addEventListener('keydown', (ev) => { + if (!(ev.ctrlKey || ev.metaKey) || ev.shiftKey || ev.altKey) return; + const k = ev.key.toLowerCase(); + if (k !== 'c' && k !== 'v') return; + if (isTypingTarget(document.activeElement)) return; + if (k === 'c' && String(window.getSelection?.() || '').trim()) return; // copying page text + const timeline = getEl('comboTimeline'); + const inputsEl = getEl('comboInputs'); + if (!timeline || !inputsEl) return; + const tokens = splitInputsTokens(inputsEl.value || ''); + + if (k === 'c') { + const tile = timeline.querySelector('.step-selected'); + const idx = tokenIndicesForTile(tile, tokens); + if (!idx.length) return; + ev.preventDefault(); + tileClipboard = idx.map((i) => tokens[i]); + navigator.clipboard?.writeText(tileClipboard.join(', ')).catch(() => {}); + if (typeof showToast === 'function') showToast('Step copied. Point at a spot and press Ctrl+V.'); + return; + } + + if (!tileClipboard || !tileClipboard.length) return; + // The tile under the mouse, else the nearest one; else the end of the combo. + const tiles = [...timeline.children].filter((c) => (c.dataset.stepIndices || '').trim()); + let target = null; + let best = Infinity; + tiles.forEach((t) => { + const r = t.getBoundingClientRect(); + const dx = Math.max(r.left - mousePos.x, 0, mousePos.x - r.right); + const dy = Math.max(r.top - mousePos.y, 0, mousePos.y - r.bottom); + const d = dx * dx + dy * dy; + if (d < best) { best = d; target = t; } + }); + let at = tokens.length; + if (target) { + const idx = tokenIndicesForTile(target, tokens); + if (idx.length) { + const r = target.getBoundingClientRect(); + at = mousePos.x < r.left + r.width / 2 ? idx[0] : idx[idx.length - 1] + 1; + } + } + ev.preventDefault(); + tokens.splice(at, 0, ...tileClipboard); + pushEditStepsUndoSnapshot(); + saveComboInputs(tokens.join(', ')); +}); diff --git a/static/app/core.js b/static/app/core.js index 7be6c78..820ea32 100644 --- a/static/app/core.js +++ b/static/app/core.js @@ -49,7 +49,6 @@ const appState = { collapseChainedPresses: true, showMoveNames: readStoredFlag('showMoveNames', true), autoScrollEnabled: false, - stepEditMode: true, editStepsUndoStack: [], targetGame: 'wuthering_waves', wwAbilityImages: { "1": {}, "2": {}, "3": {} }, diff --git a/static/app/practice.js b/static/app/practice.js index 593fde1..68982b7 100644 --- a/static/app/practice.js +++ b/static/app/practice.js @@ -373,15 +373,6 @@ if (moveNamesToggleEl) { }); } -const stepEditToggleEl = getEl('stepEditToggle'); -if (stepEditToggleEl) { - stepEditToggleEl.checked = !!appState.stepEditMode; - stepEditToggleEl.addEventListener('change', () => { - appState.stepEditMode = stepEditToggleEl.checked; - refreshTimelineIfLoaded(); - }); -} - const collapseChainsToggleEl = getEl('collapseChainsToggle'); if (collapseChainsToggleEl) { collapseChainsToggleEl.checked = !!appState.collapseChainedPresses; diff --git a/static/app/shell.js b/static/app/shell.js index 1a2bf29..e258f47 100644 --- a/static/app/shell.js +++ b/static/app/shell.js @@ -57,6 +57,39 @@ getEl('railToggle')?.addEventListener('click', () => { writeStoredFlag('ctRailCompact', compact); }); +// Drag the rail's right edge to resize it (remembered per browser); double-click resets. +const RAIL_WIDTH_KEY = 'ctRailWidth'; +const RAIL_MIN = 200, RAIL_MAX = 480, RAIL_DEFAULT = 256; +function setRailWidth(px) { + const w = Math.round(Math.min(RAIL_MAX, Math.max(RAIL_MIN, px))); + appEl.style.setProperty('--rail-w', `${w}px`); + return w; +} +try { + const saved = Number(localStorage.getItem(RAIL_WIDTH_KEY)); + if (saved) setRailWidth(saved); +} catch (_) { /* ignore */ } +const railResizer = getEl('railResizer'); +railResizer?.addEventListener('pointerdown', (e) => { + e.preventDefault(); + railResizer.setPointerCapture(e.pointerId); + document.body.classList.add('resizing'); + let width = Math.round(document.querySelector('.rail').getBoundingClientRect().width); + const move = (ev) => { width = setRailWidth(ev.clientX); }; + const up = () => { + railResizer.removeEventListener('pointermove', move); + document.body.classList.remove('resizing'); + try { localStorage.setItem(RAIL_WIDTH_KEY, String(width)); } catch (_) { /* ignore */ } + }; + railResizer.addEventListener('pointermove', move); + railResizer.addEventListener('pointerup', up, { once: true }); + railResizer.addEventListener('pointercancel', up, { once: true }); +}); +railResizer?.addEventListener('dblclick', () => { + setRailWidth(RAIL_DEFAULT); + try { localStorage.removeItem(RAIL_WIDTH_KEY); } catch (_) { /* ignore */ } +}); + // The combo list lives in the rail; without a rail (phones) it moves to the top of the Practice page. const comboListCard = getEl('comboListCard'); const railSpacer = document.querySelector('.rail-spacer'); @@ -426,6 +459,16 @@ fetch('data/ww_characters.json') }) .catch(() => { /* move names fall back to generic rules */ }); +// WuwaLAB's per-ability Concerto, so the timeline can tell when a character's bar is full. +fetch('data/ww_timings.json') + .then(res => (res.ok ? res.json() : null)) + .then(doc => { + if (!doc) return; + setWwTimingData(doc); + refreshTimelineIfLoaded(); + }) + .catch(() => { /* no Concerto tracking */ }); + tracker = connectTracker({ onOpen: () => { console.log('Connected to WuWa Combo Tracker backend'); diff --git a/static/app/timeline.js b/static/app/timeline.js index b58ab17..8619340 100644 --- a/static/app/timeline.js +++ b/static/app/timeline.js @@ -165,7 +165,10 @@ function splitInputsTokens(str) { const out = []; let buf = ''; let paren = 0, brace = 0, bracket = 0; + let quoted = false; // inside a "Move Name" (it may hold commas) for (const ch of (str || '')) { + if (ch === '"') { quoted = !quoted; buf += ch; continue; } + if (quoted) { buf += ch; continue; } if (ch === '(') paren++; else if (ch === ')') paren = Math.max(0, paren - 1); else if (ch === '{') brace++; @@ -233,6 +236,19 @@ function parseDurationToMs(raw) { * `s` is the step data dict from the backend. * `oldSourceToken` — original combo-input token when rebuilding complex holds (hold-with-body). */ +// A step can carry the move it casts, picked from its right-click menu: +// lmb "Basic: Origin Calculus 2 (Dodge Counter)". Same as split_move_name in parser.py. +function splitMoveName(token) { + const m = String(token || '').match(/\s*"([^"]*)"\s*$/); + if (!m) return [String(token || ''), null]; + return [String(token).slice(0, m.index), m[1]]; +} + +function withMoveName(token, name) { + const base = splitMoveName(token)[0].trim(); + return name ? `${base} "${name.replace(/"/g, '')}"` : base; +} + function extractHoldWithBodyParts(oldSourceToken) { const t = (oldSourceToken || '').trim(); if (!t.toLowerCase().startsWith('hold(') || !t.endsWith(')')) return null; @@ -248,6 +264,14 @@ function extractHoldWithBodyParts(oldSourceToken) { return { key, durPart, bodyPart }; } +// -wait:Xs (alone or after a key): the next press may cut the wait short. +function markOptionalWait(el, s) { + const optionalWait = s.wait_optional || (s.type === 'wait' && s.optional); + if (!optionalWait) return; + el.classList.add('wait-optional'); + el.title = 'Optional wait: pressing the next key early is fine'; +} + function reconstructTokensForEdit(s, field, newValue, oldSourceToken) { const val = (newValue || '').trim().toLowerCase(); if (!val) return null; @@ -263,7 +287,7 @@ function reconstructTokensForEdit(s, field, newValue, oldSourceToken) { if (!key) return null; const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; if (!durMs) return null; - return [key, `wait:${formatDurationToken(durMs)}`]; + return [`${s.optional ? '-' : ''}${key}`, `${s.wait_optional ? '-' : ''}wait:${formatDurationToken(durMs)}`]; } if (s.type === 'hold') { @@ -312,7 +336,7 @@ function reconstructTokensForEdit(s, field, newValue, oldSourceToken) { // Standalone soft/hard wait: one token wait:Xs const durMs = parseDurationToMs(val); if (!durMs) return null; - return [`wait:${formatDurationToken(durMs)}`]; + return [`${s.optional ? '-' : ''}wait:${formatDurationToken(durMs)}`]; } return null; @@ -372,9 +396,10 @@ function commitStepFieldEdit(runtimeIdx, s, field, newValue) { if (!srcIndices || srcIndices.length === 0) return false; const minSrc = Math.min(...srcIndices); - const oldSourceToken = currentTokens[minSrc]; - const newTokens = reconstructTokensForEdit(s, field, newValue, oldSourceToken); + const [oldSourceToken, moveName] = splitMoveName(currentTokens[minSrc]); + const newTokens = reconstructTokensForEdit(s, field, newValue, oldSourceToken.trim()); if (!newTokens) return false; + if (moveName) newTokens[0] = withMoveName(newTokens[0], moveName); // keep a picked move // Splice: replace source token(s) at srcIndices with newTokens. const result = []; @@ -392,12 +417,17 @@ function commitStepFieldEdit(runtimeIdx, s, field, newValue) { } if (result.length === 0) return false; - const newInputs = result.join(', '); pushEditStepsUndoSnapshot(); + saveComboInputs(result.join(', ')); + return true; +} + +/** Put new inputs in the Inputs box and save them, the same way as the Save button. */ +function saveComboInputs(newInputs) { + const inputsEl = getEl('comboInputs'); + if (!inputsEl) return; inputsEl.value = newInputs; if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); - - // Save via the same path as the Save/Update button. const toggle = getEl('stepDisplayToggle'); sendMessage('save_combo', { name: (getEl('comboName')?.value || '').toString(), @@ -411,6 +441,22 @@ function commitStepFieldEdit(runtimeIdx, s, field, newValue) { target_game: appState.targetGame, ww_team_id: appState.wwTeamId || '', }); +} + +/** + * Pick (or clear, with name null) the move for the timeline step at runtimeIdx: writes + * lmb "Basic: X 1" into its input token and saves. Returns false if it can't be mapped. + */ +function setStepMoveName(runtimeIdx, name) { + const inputsEl = getEl('comboInputs'); + if (!inputsEl) return false; + const tokens = splitInputsTokens(inputsEl.value || ''); + const src = buildRuntimeToSourceMap(tokens)[runtimeIdx]; + if (!src || !src.length) return false; + const i = Math.min(...src); + tokens[i] = withMoveName(tokens[i], name); + pushEditStepsUndoSnapshot(); + saveComboInputs(tokens.join(', ')); return true; } @@ -422,15 +468,15 @@ function buildRuntimeToSourceMap(tokens) { const srcMap = []; let i = 0; while (i < tokens.length) { - const tok = tokens[i].trim().toLowerCase(); + const tok = splitMoveName(tokens[i])[0].trim().toLowerCase(); if (!tok) { i++; continue; } // press + following soft/hard wait -> one runtime SequenceNode (press_wait tile) - if (!tok.startsWith('wait') && !tok.startsWith('hold(') && !tok.startsWith('spam(') && !tok.startsWith('[') && !tok.startsWith('{')) { + if (!tok.startsWith('wait') && !tok.startsWith('-wait') && !tok.startsWith('hold(') && !tok.startsWith('spam(') && !tok.startsWith('[') && !tok.startsWith('{')) { // Could be a plain press followed by wait:Xs if (i + 1 < tokens.length) { - const nxt = tokens[i + 1].trim().toLowerCase(); - if (nxt.startsWith('wait:')) { + const nxt = splitMoveName(tokens[i + 1])[0].trim().toLowerCase(); + if (nxt.startsWith('wait:') || nxt.startsWith('-wait:')) { srcMap.push([i, i + 1]); i += 2; continue; @@ -472,11 +518,10 @@ function buildRuntimeToSourceMap(tokens) { } /** - * Make a span inline-editable on double-click when edit mode is active. + * Make a span inline-editable on double-click. * `s` = step dict, `field` = 'key' | 'duration', `runtimeIdx` = first runtime index. */ function attachInlineEdit(span, s, field, runtimeIdx) { - if (!appState.stepEditMode) return; span.classList.add('step-field-editable'); span.title = 'Double-click to edit'; @@ -556,16 +601,47 @@ function updateTimeline(steps, opts) { }; // Names each step's move ("Zani Basic 2"); steps must be labeled in timeline order. - const labelMove = appState.showMoveNames ? createWwMoveLabeler(wwSlotNames()) : null; + // A later step can rename an earlier one (E, E, E on Augusta becomes Strike, Leap, Plunge). + // It also runs with names hidden, for the right-click "which move is this" list. + const showNames = !!appState.showMoveNames; + const labelMove = createWwMoveLabeler(wwSlotNames()); + const moveLabelEls = []; + // Moves picked from the right-click menu live in the saved inputs: lmb "Basic: X 1". + const savedTokens = splitInputsTokens(appState.savedInputs || ''); + const savedSrcMap = buildRuntimeToSourceMap(savedTokens); + // The one input token behind a tile, or -1 (a collapsed chain or group spans several). + const tokenForStep = (step) => { + const idx = Array.isArray(step && step.step_indices) ? step.step_indices : []; + const toks = new Set(idx.map((r) => (savedSrcMap[r] && savedSrcMap[r].length ? Math.min(...savedSrcMap[r]) : -1))); + return toks.size === 1 ? [...toks][0] : -1; + }; + const namedTiles = []; // tile of each named step, in labeler order (for onRevise) + labelMove.onRevise = (index, text) => { + const mc = namedTiles[index] && namedTiles[index]._moveChoice; + if (mc && mc.chosen) return; // a picked move stays + if (mc) mc.guess = mc.choices.find((n) => wwShortMoveName(n) === text) || mc.guess; + const el = moveLabelEls[index]; + if (el) { el.title = el.title.replace(el.textContent, text); el.textContent = text; } + }; function appendMoveLabel(tile, step, slot) { - if (!labelMove) return; - const text = labelMove(step, slot); - if (!text) return; + const tokIdx = tokenForStep(step); + const chosen = tokIdx >= 0 ? splitMoveName(savedTokens[tokIdx])[1] : null; + const text = labelMove(step, slot, chosen); + if (tokIdx >= 0 && text && labelMove.choices.length > 1) { + tile._moveChoice = { + runtimeIdx: step.step_indices[0], choices: labelMove.choices, inputs: labelMove.choiceInputs, + frames: labelMove.choiceFrames, chosen, guess: labelMove.choices[0], + }; + } + if (text) namedTiles.push(tile); + if (!text || !showNames) { if (text) moveLabelEls.push(null); return; } const el = document.createElement('span'); el.className = 'step-move'; el.textContent = text; - el.title = text; + const conc = slot ? labelMove.concerto(slot) : 0; + el.title = conc > 0 ? `${text}\nConcerto after this ≈ ${Math.round(conc)}/100` : text; tile.appendChild(el); + moveLabelEls.push(el); } const viewport = getEl('comboTimelineViewport'); @@ -615,28 +691,10 @@ function updateTimeline(steps, opts) { .map(v => Number.parseInt(v, 10)) .filter(v => Number.isFinite(v) && v >= 0); }; - const attachStepDeleteControl = (el, stepIndices) => { - const indices = parseStepIndices(stepIndices); - if (!appState.stepEditMode || indices.length === 0) return; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'step-delete-btn'; - btn.title = 'Delete this step'; - btn.setAttribute('aria-label', 'Delete this step'); - btn.textContent = '🗑'; - btn.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - pushEditStepsUndoSnapshot(); - sendMessage('delete_timeline_step', { step_indices: indices }); - }); - el.appendChild(btn); - }; - // Drag-to-reorder: the first runtime index stored in step_indices is used as the drag handle identifier. const attachStepDragControl = (el, stepIndices) => { const indices = parseStepIndices(stepIndices); - if (!appState.stepEditMode || indices.length === 0) return; + if (indices.length === 0) return; const fromRuntimeIdx = indices[0]; el.setAttribute('draggable', 'true'); el.dataset.runtimeIdx = String(fromRuntimeIdx); @@ -731,6 +789,7 @@ function updateTimeline(steps, opts) { } if (it.optional) el.classList.add('optional'); if (it.optional && it.completed && !it.was_skipped) el.classList.add('was-pressed'); + markOptionalWait(el, it); if (it.active) el.classList.add('active'); if (it.completed) el.classList.add('completed'); @@ -802,7 +861,6 @@ function updateTimeline(steps, opts) { } }); tile.appendChild(items); - attachStepDeleteControl(tile, s.step_indices); attachStepDragControl(tile, s.step_indices); return { tile, nextActiveChar: nextChar }; } @@ -838,6 +896,7 @@ function updateTimeline(steps, opts) { itEl.className = 'step sequence-item'; if (it.optional) itEl.classList.add('optional'); if (it.optional && it.completed && !it.was_skipped) itEl.classList.add('was-pressed'); + markOptionalWait(itEl, it); if (it.active) itEl.classList.add('active'); if (it.completed) itEl.classList.add('completed'); appendStepContent(itEl, it, nextChar, ctx); @@ -845,7 +904,6 @@ function updateTimeline(steps, opts) { items.appendChild(itEl); }); tile.appendChild(items); - attachStepDeleteControl(tile, s.step_indices); attachStepDragControl(tile, s.step_indices); return { tile, nextActiveChar: nextChar }; } @@ -879,6 +937,7 @@ function updateTimeline(steps, opts) { if (s.type) tile.classList.add(s.type.replace(/_/g, '-')); if (s.optional) tile.classList.add('optional'); if (s.optional && s.completed && !s.was_skipped) tile.classList.add('was-pressed'); + markOptionalWait(tile, s); let pct = (s.progress !== undefined) ? s.progress : (s.completed ? 100 : 0); if (s.type === 'wait' || s.type === 'press_wait' || s.type === 'spam') { tile.style.setProperty('--wait-pct', `${pct}%`); @@ -899,7 +958,6 @@ function updateTimeline(steps, opts) { appendStepContent(tile, s, nextChar, ctx, runtimeIdxNormal); appendMoveLabel(tile, s, nextChar); - attachStepDeleteControl(tile, s.step_indices); attachStepDragControl(tile, s.step_indices); return { tile, nextActiveChar: nextChar }; } @@ -1322,3 +1380,121 @@ function applyAutoScroll(scrollOpts) { timeline.style.transform = `translateX(${newX}px)`; } + +// --------------------------------------------------------------------------- +// Right-click menu on Combo Steps tiles (replaces the browser's menu there only; text boxes +// keep theirs). Add entries in tileMenuItems. +// --------------------------------------------------------------------------- + +// Entries for a tile: { label, run, danger?, checked?, separator? }. +function tileMenuItems(tile, indices) { + const items = []; + // Which move this step is, when the keys alone could mean more than one (dodge into A1 vs + // Dodge Counter, Heavy 1 vs Heavy 2, ...). The pick is saved in the inputs. + const mc = tile._moveChoice; + if (mc) { + items.push({ heading: 'Which move is this?' }); + // One move is always checked: the pick, else the best guess. Picking the best guess clears + // the saved pick (it's the default anyway); clicking the checked move does nothing. + const current = (mc.chosen && mc.choices.find((n) => n.toLowerCase() === mc.chosen.toLowerCase())) || mc.guess; + mc.choices.forEach((name, i) => items.push({ + label: wwShortMoveName(name), + hint: mc.inputs[i], + frames: mc.frames && mc.frames[i], + title: name, + checked: name === current, + run: () => { + if (name === current) return; + setStepMoveName(mc.runtimeIdx, name === mc.guess ? null : name); + }, + })); + items.push({ separator: true }); + } + items.push({ + label: 'Delete step', + danger: true, + run: () => { + pushEditStepsUndoSnapshot(); + sendMessage('delete_timeline_step', { step_indices: indices }); + }, + }); + return items; +} + +let tileMenuEl = null; + +function closeTileMenu() { + if (!tileMenuEl) return; + tileMenuEl.remove(); + tileMenuEl = null; +} + +function openTileMenu(x, y, tile, indices) { + closeTileMenu(); + const menu = document.createElement('div'); + menu.className = 'ctx-menu'; + menu.setAttribute('role', 'menu'); + tileMenuItems(tile, indices).forEach((item) => { + if (item.separator) { + const hr = document.createElement('div'); + hr.className = 'ctx-menu-sep'; + menu.appendChild(hr); + return; + } + if (item.heading) { + const h = document.createElement('div'); + h.className = 'ctx-menu-heading'; + h.textContent = item.heading; + menu.appendChild(h); + return; + } + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `ctx-menu-item${item.danger ? ' danger' : ''}${item.checked ? ' checked' : ''}`; + btn.setAttribute('role', item.checked !== undefined ? 'menuitemradio' : 'menuitem'); + if (item.checked !== undefined) btn.setAttribute('aria-checked', String(!!item.checked)); + if (item.title) btn.title = item.title; + btn.textContent = item.label; + if (item.frames) { + const f = document.createElement('span'); + f.className = 'ctx-menu-frames'; + f.textContent = item.frames; + btn.appendChild(f); + } + if (item.hint) { + const hint = document.createElement('code'); + hint.className = 'ctx-menu-hint'; + hint.textContent = item.hint; + btn.appendChild(hint); + } + btn.addEventListener('click', () => { closeTileMenu(); item.run(); }); + menu.appendChild(btn); + }); + document.body.appendChild(menu); + // Keep it on screen. + const r = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - r.width - 4))}px`; + menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - r.height - 4))}px`; + tileMenuEl = menu; + menu.querySelector('button')?.focus(); +} + +{ + const timeline = getEl('comboTimeline'); + if (timeline) { + timeline.addEventListener('contextmenu', (ev) => { + const tile = [...timeline.children].find((c) => c.contains(ev.target)); + const indices = (tile?.dataset.stepIndices || '') + .split(',').map((v) => Number.parseInt(v, 10)).filter((v) => Number.isFinite(v) && v >= 0); + if (!indices.length) return; + ev.preventDefault(); + openTileMenu(ev.clientX, ev.clientY, tile, indices); + }); + } + document.addEventListener('mousedown', (ev) => { if (tileMenuEl && !tileMenuEl.contains(ev.target)) closeTileMenu(); }, true); + document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeTileMenu(); }); + window.addEventListener('blur', closeTileMenu); + window.addEventListener('resize', closeTileMenu); + // wheel, not scroll: the timeline scrolls itself on updates, which shouldn't close the menu. + document.addEventListener('wheel', closeTileMenu, { passive: true }); +} diff --git a/static/characters.css b/static/characters.css index fffe076..1370a14 100644 --- a/static/characters.css +++ b/static/characters.css @@ -13,6 +13,7 @@ --line: #232933; --line-strong: #343C48; --radius: 10px; + --roster-w: 360px; --el-aero: #55d6a8; --el-electro: #b67cff; @@ -115,11 +116,9 @@ input[type="search"], select, textarea { /* --- Layout --- */ .layout { display: grid; - grid-template-columns: minmax(260px, 360px) 1fr; - gap: 20px; + grid-template-columns: var(--roster-w) 12px minmax(0, 1fr); + gap: 4px; padding: 20px; - max-width: 1400px; - margin: 0 auto; align-items: start; } @@ -163,6 +162,8 @@ input[type="search"], select, textarea { border-radius: var(--radius); padding: 20px; min-height: 300px; + min-width: 0; /* let the wide Timings table scroll inside it instead of widening the page */ + grid-column: 3; /* stay in the wide column even if the resize bar is missing (stale cached HTML) */ } .empty, .loading { color: var(--text-secondary); padding: 40px 0; text-align: center; } .error { color: var(--accent-red); padding: 20px 0; } @@ -204,15 +205,87 @@ input[type="search"], select, textarea { .move-card h3 { margin: 0 0 10px; font-size: 15px; } .move-card p { margin: 6px 0 0; } .move-card.notes p { color: #cfd5dc; } +.move-card.notes h3 .small { font-weight: 400; margin-left: 6px; } +#charNotes { + width: 100%; box-sizing: border-box; resize: vertical; min-height: 84px; + background: var(--bg-primary); color: #cfd5dc; border: 1px solid var(--line); border-radius: 6px; + padding: 8px 10px; font: inherit; font-size: 14px; line-height: 1.5; +} +#charNotes:focus { outline: none; border-color: var(--accent-blue); } .hits { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 8px; } .hits li { display: inline-flex; align-items: center; gap: 8px; background: var(--bg-secondary); border-radius: 8px; padding: 5px 10px 5px 6px; } .keycap.hit { color: var(--accent-blue); } .entries, .follow { margin: 8px 0 0 18px; display: flex; flex-direction: column; gap: 4px; } -.moves { border-collapse: collapse; width: 100%; } -.moves td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } -.moves td.inp { width: 1%; white-space: nowrap; } -.other-moves { margin-top: 12px; font-size: 13px; } -.other-moves h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-secondary); margin: 0 0 4px; } + +/* --- Timings (WuwaLAB frame data) --- */ +.toolbar #timingFilter { width: 220px; } +.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; } +.seg button { border: none; border-radius: 0; padding: 4px 10px; font-size: 12px; background: transparent; color: var(--text-secondary); } +.seg button.on { background: #152E38; color: var(--text-primary); } +.timings-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-primary); } +.timings { border-collapse: collapse; width: 100%; font-size: 13px; } +.timings th { + font-family: ui-monospace, Consolas, monospace; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + text-align: left; + padding: 10px 10px; + border-bottom: 1px solid var(--line); + white-space: nowrap; + cursor: help; +} +.timings th.strong { color: var(--accent-yellow); } +.timings td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } +.timings .num { text-align: right; font-family: ui-monospace, Consolas, monospace; white-space: nowrap; } +.timings td.strong { font-weight: 700; color: var(--text-primary); } +.timings .zero { color: #4E5866; } +.timings .tname { min-width: 220px; } +.timings .hitf { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--text-secondary); min-width: 140px; } +.timings .sep { color: #4E5866; } +.timings .tinput { white-space: nowrap; } +.timings .tinput code { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--accent-blue); background: #152E38; border-radius: 4px; padding: 2px 6px; } +.timings tr.tsection td { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--accent-red); + padding-top: 16px; +} +.timings tbody tr:not(.tsection):not(.tstrip):hover { background: var(--bg-secondary); } +.timings tr.trow { cursor: pointer; } +.timings tr.trow.open, .timings tr.tstrip { background: var(--bg-secondary); } +.timings tr.trow.open td { border-bottom-color: transparent; } +.timings .caret { display: inline-block; width: 14px; color: var(--text-secondary); font-size: 10px; transition: transform 0.12s; } +.timings tr.trow.open .caret { transform: rotate(90deg); } +.timings tr.tstrip td { padding: 4px 24px 18px; } +.fstrip { position: relative; height: 56px; background: var(--bg-primary); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; } +.fstrip .hit { position: absolute; top: 12px; bottom: 6px; width: 2px; margin-left: -1px; background: var(--accent-blue, #4A9EFF); } +.fstrip .after-cancel { position: absolute; top: 0; bottom: 0; right: 0; background: rgba(255,255,255,0.06); } +.fstrip .zone { position: absolute; height: 12px; font-size: 9px; line-height: 12px; padding-left: 3px; font-family: ui-monospace, Consolas, monospace; overflow: hidden; } +.fstrip .zone.ms { top: 0; background: rgba(244,183,64,0.18); color: var(--accent-yellow); } +.fstrip .zone.ts { bottom: 0; background: rgba(46,197,234,0.16); color: var(--accent-blue); } +.timings .conc { color: var(--accent-blue); } +.timings .neg { color: var(--accent-red); } +.fstrip .cancel-line { position: absolute; top: 0; bottom: 0; border-left: 1px dashed var(--accent-yellow); } +.fstrip-axis { position: relative; height: 16px; margin-top: 3px; font-family: ui-monospace, Consolas, monospace; font-size: 10px; color: var(--text-secondary); } +.fstrip-axis .tick-lbl { position: absolute; transform: translateX(-50%); white-space: nowrap; } +.fstrip-axis .tick-lbl.start { transform: none; } +.fstrip-axis .tick-lbl.end { transform: translateX(-100%); } +.fstrip-axis .tick-lbl.cancel { color: var(--accent-yellow); } +.ttags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } +.ttag { + font-family: ui-monospace, Consolas, monospace; + font-size: 10px; + letter-spacing: 0.04em; + color: var(--accent-yellow); + border: 1px solid var(--accent-yellow); + border-radius: 3px; + padding: 0 5px; +} /* --- Rotations --- */ .detail h3 { font-size: 15px; margin: 18px 0 10px; } @@ -258,9 +331,26 @@ input[type="search"], select, textarea { .as-inputs .row { display: flex; flex-wrap: wrap; gap: 6px; } .src-line { margin-top: 14px; font-size: 12px; color: var(--text-secondary); } +/* Drag handle between the roster and the detail panel (characters.js) */ +.splitter { + position: sticky; + top: 76px; + align-self: stretch; + max-height: calc(100vh - 96px); + cursor: col-resize; + display: flex; + justify-content: center; +} +.splitter::before { content: ''; width: 2px; border-radius: 1px; background: var(--line); } +.splitter:hover::before, +.resizing .splitter::before { background: var(--accent-blue); } +.resizing { cursor: col-resize; user-select: none; } + /* --- Phones / narrow windows --- */ @media (max-width: 900px) { - .layout { grid-template-columns: 1fr; padding: 12px; } + .layout { grid-template-columns: 1fr; padding: 12px; gap: 20px; } + .splitter { display: none; } + .detail { grid-column: auto; } .roster { position: static; max-height: 260px; } .skill-body { grid-template-columns: 1fr; } .team { grid-template-columns: 1fr auto; } @@ -268,6 +358,8 @@ input[type="search"], select, textarea { .topbar { padding: 10px 12px; position: static; } .topbar input[type="search"] { flex: 1; width: auto; } .who { width: 70px; } + .toolbar #timingFilter { flex: 1; width: auto; } + .timings .tname { min-width: 170px; } } /* Embedded as the main app's Characters page (characters.html?embed=1): the app shell has the title and nav. */ @@ -275,5 +367,6 @@ input[type="search"], select, textarea { .embed .topbar { position: static; background: transparent; padding: 14px 16px 0; border-bottom: 0; } .embed .topbar .back, .embed .topbar h1 { display: none; } -.embed .roster { top: 12px; max-height: calc(100vh - 24px); } +.embed .roster, +.embed .splitter { top: 12px; max-height: calc(100vh - 24px); } .embed .layout { padding: 16px; } diff --git a/static/characters.html b/static/characters.html index 149f645..6e28c33 100644 --- a/static/characters.html +++ b/static/characters.html @@ -23,6 +23,8 @@