From 1522a8a1a1a89be00230f8c2d7a59dcbeeeb6ebe Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 19 Sep 2026 23:27:40 -0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=8A=A0=E5=85=A540250=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/metin2-40250-parity-audit/SKILL.md | 77 +++++ .../references/audit-schema.md | 108 +++++++ .../references/project-map.md | 49 ++++ .../scripts/audit_ledger.py | 264 ++++++++++++++++++ .../skills/metin2-40250-parity-audit/SKILL.md | 6 + .cursor/rules/metin2-40250-parity-audit.mdc | 6 + .github/copilot-instructions.md | 3 + AGENTS.md | 7 + CLAUDE.md | 3 + GEMINI.md | 3 + audit/history.jsonl | 1 + audit/manifest.json | 5 + audit/reports/coverage.md | 27 ++ project/game_scene.gd | 4 +- project/net_play.gd | 55 ++-- project/net_world.gd | 5 +- project/player_controller.gd | 86 ++++-- project/test_exp_fly_parity.gd | 16 +- project/test_wasd_steering_parity.gd | 28 ++ project/ui/player_view.gd | 7 +- project/ui/view_equipment_ui.gd | 156 +++++++---- project/view_equipment_ui_test.gd | 65 +++++ 22 files changed, 876 insertions(+), 105 deletions(-) create mode 100644 .agents/skills/metin2-40250-parity-audit/SKILL.md create mode 100644 .agents/skills/metin2-40250-parity-audit/references/audit-schema.md create mode 100644 .agents/skills/metin2-40250-parity-audit/references/project-map.md create mode 100755 .agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py create mode 100644 .claude/skills/metin2-40250-parity-audit/SKILL.md create mode 100644 .cursor/rules/metin2-40250-parity-audit.mdc create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 audit/history.jsonl create mode 100644 audit/manifest.json create mode 100644 audit/reports/coverage.md create mode 100644 project/view_equipment_ui_test.gd diff --git a/.agents/skills/metin2-40250-parity-audit/SKILL.md b/.agents/skills/metin2-40250-parity-audit/SKILL.md new file mode 100644 index 00000000..5b3f60b6 --- /dev/null +++ b/.agents/skills/metin2-40250-parity-audit/SKILL.md @@ -0,0 +1,77 @@ +--- +name: metin2-40250-parity-audit +description: Audit, implement, fix, or verify behavioral parity between the Metin2 40250 Windows C++ client and this Godot client. Use whenever a request mentions 40250 comparison, 1:1 parity, missing original-client behavior, parity regressions, or continuing the client audit. Do not use for unrelated features that have no 40250 compatibility requirement. +--- + +# Metin2 40250 parity audit + +Treat the reachable 40250 implementation semantics and its observable behavior as the reference contract. The Godot architecture and APIs may differ, but its gameplay algorithms, branch conditions, state-transition order, constants, units, timing/event sources, resource/data sources, protocol side effects, and failure/cleanup behavior must not differ unless the difference is a documented platform adapter with evidence of semantic equivalence. Compare complete call chains, not filenames or similarly named functions. + +## Persistent state + +The audit ledger is the source of truth: + +- `audit/manifest.json`: current contract states and evidence links. +- `audit/history.jsonl`: append-only state-change history. +- `audit/contracts/`: detailed evidence for individual contracts. +- `audit/reports/`: generated summaries; never treat these as the source of truth. + +Before auditing, fixing, or claiming parity: + +1. Run `python3 .agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py refresh --write`. +2. Run `python3 .agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py report`. +3. Read the relevant existing contract and tests. Do not repeat a still-valid `TEST_VERIFIED` audit unless the user explicitly requests revalidation. +4. If no contract exists, add one with a stable behavior ID before marking work complete. + +Read [references/audit-schema.md](references/audit-schema.md) whenever creating or changing ledger entries. Read [references/project-map.md](references/project-map.md) when locating reference code, current implementation, existing gap documents, or test runners. + +## Audit method + +For each behavior: + +1. Define the external trigger, preconditions, state transitions, outputs, timing, interruption, failure, and cleanup behavior. +2. Trace the complete reachable 40250 call chain, including resource-driven branches and compile-time feature flags. +3. Build a branch-by-branch equivalence table mapping the reference preconditions, decisions, formulas, state writes, ordering, timing sources, resource reads, outputs, and cleanup paths to the current native extension, GDScript, resources, protocol handling, and UI. +4. Classify every difference as missing, partial, wrong order, wrong value/unit, wrong algorithm, wrong timing/event source, wrong resource/data source, duplicate, platform adapter, or intentionally excluded. +5. Treat automated output equality as necessary but not sufficient: tests can miss branches, so a different algorithm cannot be approved merely because sampled outputs currently match. +6. Fix root mechanisms rather than coordinates, entity IDs, individual assets, one-off timing constants, or simplified approximations. +7. Add or strengthen an automated test that fails before the fix and covers the reference behavior. Include boundary, rejection, interruption, and cleanup paths when they materially affect the behavior. +8. Run the narrow test first, then related subsystem tests, then `git diff --check`. +9. Update the contract's implementation-equivalence matrix, manifest, fingerprints, and history in the same change. Never mark `STATIC_VERIFIED` or `TEST_VERIFIED` without complete equivalence evidence. + +## Implementation-equivalence gate + +Source text and engine-facing APIs do not need to be identical, but the implementation must be semantically unified with 40250. Before verification, prove all of these independently: + +- identical effective preconditions and early-return rules; +- identical reachable branch structure and branch outcomes; +- equivalent algorithms and formulas, without simplified substitutes; +- identical state mutations and mutation order; +- identical constants, tolerances, coordinate conversions, and units after explicit platform conversion; +- identical timing authority and event source, such as `.msa` events rather than replacement timers; +- identical resource, table, and protocol data authority rather than hardcoded substitutes; +- identical network and externally visible side effects and their ordering; +- identical interruption, rejection, rollback, failure, and cleanup behavior. + +Permitted differences are limited to documented platform adapters such as C++ containers to Godot collections, DirectX matrices to `Transform3D`, or Windows input APIs to Godot input APIs. For every adapter, document both sides, the conversion invariant, and a focused equivalence test. If any material item differs or lacks proof, status must remain `PARTIAL` or `MAPPED`. + +## Evidence rules + +- `MAPPED` means only that both sides were located. +- `STATIC_VERIFIED` requires a documented call-chain and implementation-equivalence matrix covering every material branch. Similar output alone is insufficient. +- `TEST_VERIFIED` additionally requires meaningful automated behavior tests with a recorded passing result; tests do not waive the implementation-equivalence gate. +- Engine/platform replacement may be `EXCLUDED` only when the replacement and externally observable verification are documented. +- A source or test fingerprint change makes prior verification `STALE`; investigate only the affected contracts. +- Existing prose in `docs/CLIENT-GAP.md` and similar documents is useful evidence, but it is not a current verification state unless represented in the ledger. +- Do not claim that code inspection proves visual, timing, input-feel, driver, or Windows-specific parity. Record those limits explicitly. + +## Scope and safety + +- Preserve unrelated dirty-worktree changes. +- Prefer the active files from the 40250 Visual Studio build; do not audit disabled, third-party, or obsolete code as product behavior without evidence that it is reachable. +- Keep credentials, live-server data, copyrighted binary dependencies, generated captures, and local run outputs out of the ledger. +- Do not change live servers or external systems unless the user requested it. + +## Completion report + +Report the contract IDs changed, reference and implementation call chains, discrepancies fixed, tests run, remaining unverified branches, and resulting ledger status. A subsystem is complete only when its in-scope contracts have no unexplained `UNMAPPED`, `PARTIAL`, `STALE`, or `REGRESSION` entries. diff --git a/.agents/skills/metin2-40250-parity-audit/references/audit-schema.md b/.agents/skills/metin2-40250-parity-audit/references/audit-schema.md new file mode 100644 index 00000000..ceda07da --- /dev/null +++ b/.agents/skills/metin2-40250-parity-audit/references/audit-schema.md @@ -0,0 +1,108 @@ +# Audit ledger schema + +`audit/manifest.json` is machine-readable and version controlled. Keep one entry per externally meaningful behavior, not one entry per source file. + +## Contract fields + +Required fields: + +- `id`: stable dotted ID such as `combat.local.normal_attack`. +- `title`: concise behavior name. +- `subsystem`: `network`, `lifecycle`, `movement`, `combat`, `skill`, `world`, `item`, `ui`, `resource`, `render`, `audio`, or another stable domain. +- `priority`: `P0`, `P1`, `P2`, or `P3`. +- `status`: one of the statuses below. +- `reference.files`: paths relative to `manifest.reference_root`. +- `reference.symbols`: relevant 40250 symbols. +- `implementation.files`: paths relative to the repository root. +- `implementation.symbols`: corresponding native or GDScript symbols. +- `equivalence`: implementation-equivalence matrix described below. +- `platform_adaptations`: documented engine/platform substitutions; use an empty array when none exist. +- `evidence.contract`: detailed Markdown contract path relative to the repository root. +- `evidence.tests`: test paths relative to the repository root. +- `evidence.last_test_result`: `PASS`, `FAIL`, or `NOT_RUN`. +- `remaining`: explicit unverified branches; use an empty array only when none remain. + +Optional generated fields: + +- `fingerprints.reference`: combined SHA-256 for the listed reference files. +- `fingerprints.implementation`: combined SHA-256 for implementation files. +- `fingerprints.tests`: combined SHA-256 for test files. +- `stale_reasons`: generated reasons for invalidation. +- `verified_commit`: Git commit at the last completed verification. +- `notes`: concise information that does not belong in the detailed contract. + +## Implementation-equivalence matrix + +Every contract that reaches `STATIC_VERIFIED` or `TEST_VERIFIED` must contain all fields below with the value `VERIFIED`: + +```json +{ + "equivalence": { + "preconditions": "VERIFIED", + "branch_structure": "VERIFIED", + "algorithms_formulas": "VERIFIED", + "state_transition_order": "VERIFIED", + "constants_units": "VERIFIED", + "timing_event_sources": "VERIFIED", + "resource_data_sources": "VERIFIED", + "protocol_side_effects": "VERIFIED", + "interruption_failure_cleanup": "VERIFIED" + } +} +``` + +`VERIFIED` means the detailed contract contains a branch-by-branch comparison and no material semantic difference. Matching a few outputs or passing only happy-path tests is not enough. + +Language and engine boundary substitutions belong in `platform_adaptations`: + +```json +{ + "platform_adaptations": [ + { + "reference": "D3DXMATRIX row-vector transform", + "implementation": "Godot Transform3D column-vector transform", + "invariant": "actor, offset, and bone composition produces the same world-space transform", + "tests": ["project/example_transform_parity_test.gd"] + } + ] +} +``` + +An adaptation may change APIs or representation, never gameplay rules, branch outcomes, timing authority, or data authority. Missing equivalence proof keeps the contract at `MAPPED` or `PARTIAL`. + +## Status meanings + +- `UNMAPPED`: reference behavior has no located implementation. +- `PARTIAL`: known material behavior or branches are missing. +- `MAPPED`: both sides are located but not fully compared. +- `STATIC_VERIFIED`: complete material call-chain and implementation-equivalence comparison is documented. +- `TEST_VERIFIED`: static equivalence verification plus meaningful passing tests. +- `IN_PROGRESS`: currently being audited; do not use as a long-term resting state. +- `STALE`: reference, implementation, or test evidence changed after verification. +- `REGRESSION`: a previously passing behavior test now fails. +- `BLOCKED`: concrete missing input or dependency prevents progress. +- `EXCLUDED`: not ported by design; requires `exclusion_reason` and replacement verification. + +## Verification gates + +`STATIC_VERIFIED` and `TEST_VERIFIED` both require: + +1. Non-empty reference and implementation file lists. +2. Existing detailed contract document. +3. Every implementation-equivalence field set to `VERIFIED` with supporting detail in the contract. +4. Every platform adaptation documented with its invariant and focused tests. +5. No unresolved material branch in `remaining`. + +`TEST_VERIFIED` additionally requires at least one existing automated test and `last_test_result` equal to `PASS`. + +When a verified entry becomes stale, retain its previous evidence and history. Do not delete the entry or recreate it under a new ID. + +## History events + +Append one compact JSON object per meaningful state change to `audit/history.jsonl`: + +```json +{"time":"2026-09-19T00:00:00Z","event":"status_changed","id":"combat.local.normal_attack","from":"MAPPED","to":"TEST_VERIFIED","reason":"call chain audited and tests passed"} +``` + +Never store credentials, packet payload secrets, binary captures, or generated screenshots in the ledger. diff --git a/.agents/skills/metin2-40250-parity-audit/references/project-map.md b/.agents/skills/metin2-40250-parity-audit/references/project-map.md new file mode 100644 index 00000000..62a893e1 --- /dev/null +++ b/.agents/skills/metin2-40250-parity-audit/references/project-map.md @@ -0,0 +1,49 @@ +# Project map + +## Reference client + +- Root: `../40250/Server Client TMP4/ClientVS22/source` +- High-level game and packet flow: `UserInterface` +- actors, motion, maps, flying objects: `GameLib` +- rendering, input, collision primitives: `EterLib` +- effects: `EffectLib` +- Granny integration: `EterGrnLib` +- audio: `MilesLib` +- terrain: `PRTerrainLib` + +Use the Visual Studio project and active preprocessor flags to distinguish reachable product code from disabled, obsolete, and third-party code. + +## Current client + +- GDScript runtime and tests: `project/` +- native extension: `extension/` +- format readers: `formats/` +- shared/native libraries: `libgr2/` +- extracted assets and tables: `assets/` +- numerical reference oracle: `oracle/` +- host-side tools: `tools/` + +## Existing evidence + +- `docs/CLIENT-GAP.md`: historical broad gap analysis; migrate useful claims into contracts rather than trusting status text. +- `docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md`: existing methodology and high-risk domains. +- `docs/CLIENT-40250-PORT.md`: porting context. +- `docs/PARITY-GAP.md`: visual/rendering gap notes. +- `oracle/run-diff-suite.sh`: Granny numerical comparison suite. +- `project/test_*_parity.gd` and `project/*_test.gd`: existing tests; inspect assertions before treating them as evidence. + +## Typical verification commands + +Run a narrow Godot test with: + +```bash +godot --headless --path project --script project/test_name.gd +``` + +Some scripts expect the path relative to `project/` instead: + +```bash +godot --headless --path project --script test_name.gd +``` + +Follow the convention already used by the selected test. Always finish code changes with `git diff --check`. diff --git a/.agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py b/.agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py new file mode 100755 index 00000000..315a5c6a --- /dev/null +++ b/.agents/skills/metin2-40250-parity-audit/scripts/audit_ledger.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Validate, refresh, and summarize the repository's 40250 parity ledger.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + + +STATUSES = { + "UNMAPPED", "PARTIAL", "MAPPED", "STATIC_VERIFIED", "TEST_VERIFIED", + "IN_PROGRESS", "STALE", "REGRESSION", "BLOCKED", "EXCLUDED", +} +PRIORITIES = {"P0", "P1", "P2", "P3"} +PENDING = {"UNMAPPED", "PARTIAL", "MAPPED", "IN_PROGRESS", "STALE", "REGRESSION", "BLOCKED"} +EQUIVALENCE_FIELDS = { + "preconditions", + "branch_structure", + "algorithms_formulas", + "state_transition_order", + "constants_units", + "timing_event_sources", + "resource_data_sources", + "protocol_side_effects", + "interruption_failure_cleanup", +} + + +def repo_root() -> Path: + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / ".git").exists(): + return parent + raise SystemExit("cannot locate repository root") + + +def manifest_path(root: Path) -> Path: + return root / "audit" / "manifest.json" + + +def load_manifest(root: Path) -> dict: + path = manifest_path(root) + if not path.exists(): + raise SystemExit(f"missing audit ledger: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def write_manifest(root: Path, data: dict) -> None: + manifest_path(root).write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def append_history(root: Path, event: dict) -> None: + event = {"time": datetime.now(timezone.utc).isoformat(), **event} + with (root / "audit" / "history.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n") + + +def resolve_files(root: Path, data: dict, section: str) -> list[Path]: + contract = data + files = contract.get(section, {}).get("files", []) if section != "tests" else contract.get("evidence", {}).get("tests", []) + base = root + if section == "reference": + ref_root = Path(data.get("_reference_root", "")) + base = ref_root if ref_root.is_absolute() else root / ref_root + return [(base / item).resolve() for item in files] + + +def fingerprint(paths: list[Path]) -> str: + digest = hashlib.sha256() + for path in sorted(paths, key=lambda p: str(p)): + digest.update(str(path).encode()) + if not path.is_file(): + digest.update(b"") + continue + digest.update(hashlib.sha256(path.read_bytes()).digest()) + return digest.hexdigest() + + +def validation_errors(root: Path, manifest: dict) -> list[str]: + errors: list[str] = [] + seen: set[str] = set() + if manifest.get("schema_version") != 1: + errors.append("schema_version must be 1") + if not isinstance(manifest.get("contracts"), list): + return errors + ["contracts must be an array"] + for index, contract in enumerate(manifest["contracts"]): + label = contract.get("id", f"contracts[{index}]") + for field in ("id", "title", "subsystem", "priority", "status"): + if not contract.get(field): + errors.append(f"{label}: missing {field}") + if label in seen: + errors.append(f"{label}: duplicate id") + seen.add(label) + if contract.get("priority") not in PRIORITIES: + errors.append(f"{label}: invalid priority {contract.get('priority')}") + if contract.get("status") not in STATUSES: + errors.append(f"{label}: invalid status {contract.get('status')}") + if contract.get("status") == "EXCLUDED" and not contract.get("exclusion_reason"): + errors.append(f"{label}: EXCLUDED requires exclusion_reason") + if contract.get("status") in {"STATIC_VERIFIED", "TEST_VERIFIED"}: + reference = contract.get("reference", {}).get("files", []) + implementation = contract.get("implementation", {}).get("files", []) + evidence = contract.get("evidence", {}) + if not reference or not implementation: + errors.append(f"{label}: verified status requires mapped files") + contract_path = evidence.get("contract", "") + if not contract_path or not (root / contract_path).is_file(): + errors.append(f"{label}: verified status requires an existing contract document") + equivalence = contract.get("equivalence", {}) + missing_equivalence = sorted( + field for field in EQUIVALENCE_FIELDS if equivalence.get(field) != "VERIFIED" + ) + if missing_equivalence: + errors.append( + f"{label}: verified status requires VERIFIED implementation equivalence for " + + ", ".join(missing_equivalence) + ) + adaptations = contract.get("platform_adaptations", []) + if not isinstance(adaptations, list): + errors.append(f"{label}: platform_adaptations must be an array") + else: + for adaptation_index, adaptation in enumerate(adaptations): + missing = [ + field for field in ("reference", "implementation", "invariant", "tests") + if not adaptation.get(field) + ] + if missing: + errors.append( + f"{label}: platform_adaptations[{adaptation_index}] missing " + + ", ".join(missing) + ) + for test in adaptation.get("tests", []): + if not (root / test).is_file(): + errors.append( + f"{label}: platform adaptation test does not exist: {test}" + ) + if contract.get("remaining"): + errors.append(f"{label}: verified status cannot have remaining material branches") + if contract.get("status") == "TEST_VERIFIED": + evidence = contract.get("evidence", {}) + tests = evidence.get("tests", []) + if not tests or any(not (root / test).is_file() for test in tests): + errors.append(f"{label}: TEST_VERIFIED requires existing tests") + if evidence.get("last_test_result") != "PASS": + errors.append(f"{label}: TEST_VERIFIED requires last_test_result PASS") + return errors + + +def command_validate(root: Path, manifest: dict, _args: argparse.Namespace) -> int: + errors = validation_errors(root, manifest) + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print(f"PASS: audit ledger is valid ({len(manifest['contracts'])} contracts)") + return 0 + + +def command_report(root: Path, manifest: dict, args: argparse.Namespace) -> int: + contracts = manifest["contracts"] + by_status = Counter(item["status"] for item in contracts) + by_priority = Counter(item["priority"] for item in contracts) + lines = ["# 40250 parity audit coverage", "", f"Total contracts: {len(contracts)}", "", "## Status", ""] + for status in sorted(STATUSES): + lines.append(f"- {status}: {by_status[status]}") + lines.extend(["", "## Priority", ""]) + for priority in sorted(PRIORITIES): + lines.append(f"- {priority}: {by_priority[priority]}") + pending = sorted( + (item for item in contracts if item["status"] in PENDING), + key=lambda item: (item["priority"], item["status"], item["id"]), + ) + lines.extend(["", "## Pending", ""]) + lines.extend(f"- [{item['priority']}] {item['id']}: {item['status']}" for item in pending) + if not pending: + lines.append("- None") + report = "\n".join(lines) + "\n" + if args.write: + out = root / "audit" / "reports" / "coverage.md" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(report, encoding="utf-8") + print(out) + else: + print(report, end="") + return 0 + + +def command_next(_root: Path, manifest: dict, args: argparse.Namespace) -> int: + pending = sorted( + (item for item in manifest["contracts"] if item["status"] in PENDING), + key=lambda item: (item["priority"], item["status"], item["id"]), + ) + for item in pending[: args.limit]: + print(f"{item['priority']}\t{item['status']}\t{item['id']}\t{item['title']}") + return 0 + + +def command_refresh(root: Path, manifest: dict, args: argparse.Namespace) -> int: + changed = 0 + ref_root = manifest.get("reference_root", "") + for contract in manifest["contracts"]: + working = {**contract, "_reference_root": ref_root} + current = { + "reference": fingerprint(resolve_files(root, working, "reference")), + "implementation": fingerprint(resolve_files(root, working, "implementation")), + "tests": fingerprint(resolve_files(root, working, "tests")), + } + old = contract.get("fingerprints", {}) + contract_changed = old != current + reasons = [name for name, value in current.items() if old.get(name) and old[name] != value] + if reasons and contract.get("status") in {"STATIC_VERIFIED", "TEST_VERIFIED"}: + previous = contract["status"] + if args.write: + contract["status"] = "STALE" + contract["stale_reasons"] = reasons + append_history(root, { + "event": "status_changed", "id": contract["id"], "from": previous, + "to": "STALE", "reason": "fingerprint changed: " + ",".join(reasons), + }) + if contract_changed: + changed += 1 + if args.write: + contract["fingerprints"] = current + if args.write and changed: + write_manifest(root, manifest) + print(f"refresh: {changed} change(s){' written' if args.write else ' detected'}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("validate") + report = sub.add_parser("report") + report.add_argument("--write", action="store_true") + next_parser = sub.add_parser("next") + next_parser.add_argument("--limit", type=int, default=20) + refresh = sub.add_parser("refresh") + refresh.add_argument("--write", action="store_true") + args = parser.parse_args() + root = repo_root() + manifest = load_manifest(root) + errors = validation_errors(root, manifest) + if errors and args.command != "validate": + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + return { + "validate": command_validate, + "report": command_report, + "next": command_next, + "refresh": command_refresh, + }[args.command](root, manifest, args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/metin2-40250-parity-audit/SKILL.md b/.claude/skills/metin2-40250-parity-audit/SKILL.md new file mode 100644 index 00000000..d6334e5b --- /dev/null +++ b/.claude/skills/metin2-40250-parity-audit/SKILL.md @@ -0,0 +1,6 @@ +--- +name: metin2-40250-parity-audit +description: Audit, implement, fix, or verify behavioral parity between the Metin2 40250 Windows C++ client and this Godot client. Use for 40250 comparison, 1:1 parity, missing original-client behavior, parity regressions, and continuation of the client audit. +--- + +This is a compatibility entry point. Read `../../../.agents/skills/metin2-40250-parity-audit/SKILL.md` completely and follow it as the canonical skill. Resolve its relative references from the canonical skill directory. diff --git a/.cursor/rules/metin2-40250-parity-audit.mdc b/.cursor/rules/metin2-40250-parity-audit.mdc new file mode 100644 index 00000000..baa77a15 --- /dev/null +++ b/.cursor/rules/metin2-40250-parity-audit.mdc @@ -0,0 +1,6 @@ +--- +description: Route Metin2 40250 comparison and parity work to the repository audit skill +alwaysApply: true +--- + +When a request concerns Metin2 40250 comparison, 1:1 parity, missing original-client behavior, or parity regression, read `.agents/skills/metin2-40250-parity-audit/SKILL.md` completely before acting. Use `audit/manifest.json` as persistent audit state. Ignore this rule for unrelated work. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..546fbf3f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Repository instructions + +For any Metin2 40250 comparison, 1:1 parity, missing original-client behavior, or parity-regression task, first read and follow `.agents/skills/metin2-40250-parity-audit/SKILL.md`. Treat `audit/manifest.json` as the persistent audit source of truth and do not repeat still-valid verified work. This specialized workflow does not apply to unrelated development tasks. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5f15064f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Project agent instructions + +## Mandatory 40250 parity workflow + +When a task asks to compare, audit, implement, fix, or verify behavior against the Metin2 40250 client, read and follow `.agents/skills/metin2-40250-parity-audit/SKILL.md` before taking task actions. Treat `audit/manifest.json` as the persistent source of truth and update its evidence when parity work changes a conclusion. + +Do not load that specialized workflow for unrelated development tasks. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..79dc7ad6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Project instructions + +For any Metin2 40250 comparison, 1:1 parity, missing original-client behavior, or parity-regression task, use the project skill `metin2-40250-parity-audit` from `.claude/skills/`. Keep audit state in `audit/manifest.json`; do not repeat still-valid verified work. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..0b1399a5 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,3 @@ +# Project instructions + +For any task involving Metin2 40250 comparison, 1:1 parity, missing original-client behavior, or parity regression, first read `.agents/skills/metin2-40250-parity-audit/SKILL.md` completely and follow it. Use `audit/manifest.json` as the persistent audit state and do not repeat still-valid verified work. diff --git a/audit/history.jsonl b/audit/history.jsonl new file mode 100644 index 00000000..56cfac79 --- /dev/null +++ b/audit/history.jsonl @@ -0,0 +1 @@ +{"time":"2026-09-19T00:00:00Z","event":"ledger_initialized","schema_version":1} diff --git a/audit/manifest.json b/audit/manifest.json new file mode 100644 index 00000000..cf4fccc4 --- /dev/null +++ b/audit/manifest.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "reference_root": "../40250/Server Client TMP4/ClientVS22/source", + "contracts": [] +} diff --git a/audit/reports/coverage.md b/audit/reports/coverage.md new file mode 100644 index 00000000..d8fd8347 --- /dev/null +++ b/audit/reports/coverage.md @@ -0,0 +1,27 @@ +# 40250 parity audit coverage + +Total contracts: 0 + +## Status + +- BLOCKED: 0 +- EXCLUDED: 0 +- IN_PROGRESS: 0 +- MAPPED: 0 +- PARTIAL: 0 +- REGRESSION: 0 +- STALE: 0 +- STATIC_VERIFIED: 0 +- TEST_VERIFIED: 0 +- UNMAPPED: 0 + +## Priority + +- P0: 0 +- P1: 0 +- P2: 0 +- P3: 0 + +## Pending + +- None diff --git a/project/game_scene.gd b/project/game_scene.gd index 19d79a66..6db8bc34 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -361,8 +361,8 @@ func setup(m2client: Node, assets_root: String, quickbar.skill_cast_started.connect(_on_skill_cast_started) if quickbar.has_signal("skill_activated"): quickbar.skill_activated.connect(func(sid: int): - if skill_fx and player: - skill_fx.spawn_skill(sid, _skill_master(sid), player) + # 40250 的本地技能视觉来自当前等级 .msa 的 MotionEventData。 + # 这里不再按技能名猜测并整套预播,否则会和动作帧特效重复且位置错误。 # Bow skills send CG_SHOOT from the .msa FLY frame, matching # CNormalBowAttack_FlyEventHandler_AutoClear::OnShoot —— 压队列, # 不再用 1.5s 定时器兜底(AutoClear 由 FLY 帧 pop 自清)。§3.6 diff --git a/project/net_play.gd b/project/net_play.gd index befb0ae8..13a7f375 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -319,6 +319,18 @@ func setup(m2client: Node, player_ctl: Node, nw: Node, hud_node: Node = null, au _observer_mode = bool(client.is_observer_mode()) if pc and "skip_actor_collision" in pc: pc.skip_actor_collision = _observer_mode + # GameScene 的资源加载会让首个 entity_main_set 早于 NetPlay.setup 到达;补读当前 + # 主角,和 NetWorld.catch_up 一样避免丢失进场状态(动作模式、本地主角 VID、目标)。 + if client.has_method("get_main_vid"): + var current_main := int(client.get_main_vid()) + if current_main > 0: + _on_main_set(current_main) + # setup 通常发生在网络已完成首轮 POINTS 同步之后。先建立经验/等级基线, + # 否则进入游戏后的第一次击杀只会把 _last_exp 从 -1 初始化,表现为没有经验反馈。 + if client.has_method("get_points"): + var initial_points: Dictionary = client.get_points() + if not initial_points.is_empty(): + _on_points(initial_points) func _on_observer_mode(enabled: bool) -> void: _observer_mode = enabled @@ -610,11 +622,23 @@ func _combo_vec() -> PackedInt32Array: return cls.get(key, PackedInt32Array()) func _motion_data() -> Dictionary: - var pv_anim: Object = player_view.get("anim") if player_view else null + var view := _combat_view() + var pv_anim: Object = view.get("anim") if view and "anim" in view else null if pv_anim and pv_anim.has_method("get_motion_data"): return pv_anim.get_motion_data() return {} +func _combat_view() -> Node: + if is_instance_valid(player_view): + return player_view + # GameScene 会在占位角色替换成 PlayerView 后注入 player_view;替换同帧发生攻击时, + # pc.player 已经是权威本地模型,因此用它兜底,避免只发包却没有攻击动作。 + if pc and "player" in pc and is_instance_valid(pc.player): + var candidate: Node = pc.player + if candidate.has_method("play_attack_motion") or candidate.has_method("set_anim_state"): + return candidate + return null + # --- helpers -------------------------------------------------------------- func _vid_of(node: Object) -> int: @@ -908,20 +932,11 @@ func _on_points(p: Dictionary) -> void: func _trigger_exp_gain(delta_exp: int, _p: Dictionary) -> void: print("[经验] 击杀怪物获得 %d 点经验值。" % delta_exp) - # 1. 40250 官方经验聚能光球飞行与吸收 - if is_instance_valid(net_world) and net_world.has_method("spawn_exp_fly"): - var spawn_pos := _last_dead_mob_pos - var player_node: Node3D = pc.player if (pc and is_instance_valid(pc.player)) else null - if spawn_pos == Vector3.INF and is_instance_valid(player_node): - # 若无死亡怪物坐标记录,则在玩家正前方 2.5 米处生成光球回吸 - var p_pos := player_node.global_position if player_node.is_inside_tree() else player_node.position - var forward := -player_node.global_transform.basis.z if player_node.is_inside_tree() else Vector3(0, 0, -1) - spawn_pos = p_pos + forward * 2.5 - if spawn_pos != Vector3.INF: - net_world.spawn_exp_fly(spawn_pos, player_node, 3) - _last_dead_mob_pos = Vector3.INF + # 40250 的经验球由服务端 `from->CreateFly(FLY_EXP, to)` 下发 GC_CREATE_FLY, + # NetWorld._on_fly 负责唯一一次播放。这里不能再合成一组,否则正常收包时会出现双份。 + _last_dead_mob_pos = Vector3.INF - # 2. 40250 吸收音效与任务栏反馈 + # 经验数值反馈音效;飞行和吸收闪光由权威 GC_CREATE_FLY 驱动。 if audio and audio.has_method("play_ui"): audio.play_ui("money.wav") @@ -2004,10 +2019,14 @@ func _emit_swing(motion_index: int, is_combo: bool) -> void: _send_state(FUNC_COMBO, motion_index, pc.player.position) # __SetMotion(SSetMotionData{ dwMotKey(mode, index), fSpeedRatio = m_fAtkSpd }):先绑定该段 # 动作,命中窗 / 连击时间才读得到这一段的 .msa。 - if player_view and player_view.has_method("play_attack_motion"): - player_view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) - elif player_view and player_view.has_method("set_anim_state"): - player_view.set_anim_state("combo" if is_combo else "attack") + var view := _combat_view() + var motion_played := false + if view and view.has_method("play_attack_motion"): + # 旧视图/测试桩的方法没有返回值;只有新版 PlayerView 明确返回 true 时才认为 + # 已成功绑定 .msa,其余情况继续走 set_anim_state 兼容兜底。 + motion_played = view.play_attack_motion(combo_motion_mode, motion_index, _atk_speed_factor) == true + if not motion_played and view and view.has_method("set_anim_state"): + view.set_anim_state("combo" if is_combo else "attack") _begin_hit_windows() if _hit_windows.is_empty(): # CG_ATTACK 的 bType 是技能号,普攻恒 0,不是连击段号 diff --git a/project/net_world.gd b/project/net_world.gd index 080ba51f..d19cb385 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -1207,7 +1207,10 @@ func _on_dead(vid: int) -> void: func _on_fly(_type: int, start_vid: int, end_vid: int) -> void: var a: Vector3 = _entity_pos(start_vid) var b: Vector3 = _entity_pos(end_vid) - var target_node: Node3D = _by_vid.get(end_vid, null) + # 本地主角按 40250 的单实例规则不在 _by_vid 中;经验球的 end_vid 正是主角, + # 必须指向 _local_node 才会追踪胸口并触发 at_target/吸收效果。 + var target_node: Node3D = _local_node if end_vid == _local_vid and is_instance_valid(_local_node) \ + else _by_vid.get(end_vid, null) var shooter: Node3D = _by_vid.get(start_vid, null) if b == Vector3.INF and shooter: var target: Dictionary = {} diff --git a/project/player_controller.gd b/project/player_controller.gd index 3f44c441..ec26a059 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -65,6 +65,13 @@ var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切 # CActorInstance::CanSkipCollision():网络状态包驱动的移动整段跳过 Actor 碰撞 # (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。 var skip_actor_collision := false +# 40250 CPythonPlayer::NEW_SetSingleDirKeyState:方向由进入游戏后的按下/松开事件 +# 维护,不能每帧查询系统全局键盘状态。否则加载场景时仍按着的键会被继承,表现为 +# 刚进入游戏角色就自动移动。 +var _key_up := false +var _key_down := false +var _key_left := false +var _key_right := false var _last_wasd := Vector2.ZERO var _mobile_axis := Vector2.ZERO var _mobile_active := false @@ -79,13 +86,21 @@ var _dst_rot := 0.0 var _reserved_ground: Variant = null # __ReserveClickGround 的待处理点 var _reserved_delay_time := 0.0 # NEW_IsEmptyReservedDelayTime 递减到 0 才生效 -var active := true # 进游戏装配/加载中置假,防止过早响应点地 +var active := true: # 进游戏装配/加载中置假,防止过早响应点地 + set(value): + if active == value: + return + active = value + # 切换输入阶段相当于 40250 game.Close/Open 重建按键状态。只有在游戏窗 + # 激活后新收到的 KeyDown 才能驱动角色。 + _clear_direction_keys() # 40250 CInstanceBase::NEW_Stop() / CPythonPlayer::NEW_Stop():停止点地移动与方向输入,重置预约 func stop() -> void: _is_going = false _reserved_ground = null _reserved_delay_time = 0.0 _last_wasd = Vector2.ZERO + _clear_direction_keys() _mobile_active = false _mobile_axis = Vector2.ZERO _mobile_ui_touches.clear() @@ -97,6 +112,39 @@ func stop() -> void: _dst_pos = player.position anim_state.emit("wait") +func _clear_direction_keys() -> void: + _key_up = false + _key_down = false + _key_left = false + _key_right = false + +func _notification(what: int) -> void: + if what == NOTIFICATION_APPLICATION_FOCUS_OUT: + # KeyUp 可能在窗口失焦期间丢失;参考客户端失去游戏窗口输入后停止移动。 + _clear_direction_keys() + _last_wasd = Vector2.ZERO + +func _input(e: InputEvent) -> void: + if not (e is InputEventKey) or e.echo: + return + var key := e as InputEventKey + # 松开事件必须始终接收,即使此时 UI 正在拦截游戏输入,否则会留下粘键。 + # KeyDown 则在 _unhandled_input 中接收,让聊天输入框有机会先消费 WASD。 + if not key.pressed: + _set_direction_key_state(key, false) + +func _set_direction_key_state(e: InputEventKey, pressed: bool) -> void: + var logical := e.keycode + var physical := e.physical_keycode + if logical in [KEY_W, KEY_UP] or physical in [KEY_W, KEY_UP]: + _key_up = pressed + elif logical in [KEY_S, KEY_DOWN] or physical in [KEY_S, KEY_DOWN]: + _key_down = pressed + elif logical in [KEY_A, KEY_LEFT] or physical in [KEY_A, KEY_LEFT]: + _key_left = pressed + elif logical in [KEY_D, KEY_RIGHT] or physical in [KEY_D, KEY_RIGHT]: + _key_right = pressed + # CInstanceBase::__IsSyncing():Dead / Stun / Pushing 中不接受移动输入(net_play 置 frozen)。 func is_going() -> bool: return _is_going @@ -184,7 +232,11 @@ func _goto(dst_flat: Vector3) -> bool: func _unhandled_input(e: InputEvent) -> void: if not active: return - if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: + if e is InputEventKey: + var key := e as InputEventKey + if key.pressed and not key.echo: + _set_direction_key_state(key, true) + elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: _on_click(e.position) elif e is InputEventScreenTouch: _on_touch(e) @@ -326,26 +378,17 @@ func _ray_ground(from: Vector3, dir: Vector3) -> Variant: return null func _wasd() -> Vector2: - if ui_manager and ui_manager.has_method("blocks_game_input"): - var dummy := InputEventKey.new() - dummy.keycode = KEY_W - if ui_manager.blocks_game_input(dummy): - return Vector2.ZERO - var is_up := Input.is_key_pressed(KEY_W) or Input.is_physical_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP) - var is_down := Input.is_key_pressed(KEY_S) or Input.is_physical_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN) - var is_left := Input.is_key_pressed(KEY_A) or Input.is_physical_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT) - var is_right := Input.is_key_pressed(KEY_D) or Input.is_physical_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT) - if not (is_up or is_down or is_left or is_right): + if not (_key_up or _key_down or _key_left or _key_right): return Vector2.ZERO var d := Vector2.ZERO # 对齐 40250 CPythonPlayer::NEW_GetMultiKeyDirRotation: Up优先于Down,Left优先于Right - if is_up: + if _key_up: d.y = -1.0 - elif is_down: + elif _key_down: d.y = 1.0 - if is_left: + if _key_left: d.x = -1.0 - elif is_right: + elif _key_right: d.x = 1.0 return d.normalized() @@ -396,18 +439,17 @@ func _actor_blocked(next_pos: Vector3) -> bool: func _turn_toward(target_yaw: float, dt: float) -> void: if player == null: return - player.rotation.x = 0.0 - player.rotation.z = 0.0 var cur_yaw: float = wrapf(player.rotation.y, -PI, PI) var diff: float = wrapf(target_yaw - cur_yaw, -PI, PI) var spd := rotation_speed_deg if absf(diff) <= deg_to_rad(45.0): spd *= 5.0 / 12.0 var max_step := deg_to_rad(spd) * dt - if max_step <= 0.0 or absf(diff) <= max_step: - player.rotation.y = target_yaw - else: - player.rotation.y = wrapf(cur_yaw + signf(diff) * max_step, -PI, PI) + var next_yaw := target_yaw if max_step <= 0.0 or absf(diff) <= max_step \ + else wrapf(cur_yaw + signf(diff) * max_step, -PI, PI) + # 只允许直立偏航。逐项修改 Euler 分量在 W/S 180° 快切和 ±PI 边界附近可能 + # 被引擎重新分解为 X/Z = PI,看起来就是角色翻跟头。 + player.rotation = Vector3(0.0, wrapf(next_yaw, -PI, PI), 0.0) func _process(dt: float) -> void: if player == null or not active: diff --git a/project/test_exp_fly_parity.gd b/project/test_exp_fly_parity.gd index 862571c0..d0ba6e85 100644 --- a/project/test_exp_fly_parity.gd +++ b/project/test_exp_fly_parity.gd @@ -88,13 +88,19 @@ func _init() -> void: var player_node := Node3D.new() player_node.position = Vector3(4, 0, 0) mount.add_child(player_node) - nw._by_vid[1] = player_node + nw.set_local_node(player_node) + nw.set_local_vid(1) # 触发 40250 官方 _on_fly(0, 100, 1) nw._on_fly(FlyObject.FLY_EXP, 100, 1) if mgr.active_count() != 3: printerr("FAIL: _on_fly(FLY_EXP) 应发射 3 颗经验聚能光球,实际: ", mgr.active_count()) fails += 1 + for orb in mgr._instances: + if not orb._is_object or orb._target_obj != player_node: + printerr("FAIL: 经验球没有锁定本地主角节点,无法产生吸收效果") + fails += 1 + break # 清空飞行物 for i in range(50): @@ -113,7 +119,7 @@ func _init() -> void: if mgr.active_count() == 0: break - # 5. 验证 net_play 经验变动时触发经验获取流程 + # 5. 验证 net_play 经验变动不重复合成服务端已经下发的经验球 var np := NetPlay.new() mount.add_child(np) var pc := PlayerCtl.new() @@ -126,11 +132,11 @@ func _init() -> void: printerr("FAIL: 初始点数同步不应触发获取经验!") fails += 1 - # 杀死怪物获得 500 经验 + # 杀死怪物获得 500 经验:视觉只由上面的 GC_CREATE_FLY 路径生成。 np._last_dead_mob_pos = Vector3(3, 0, 0) np._on_points({"exp": 1500, "next_exp": 5000, "level": 10}) - if mgr.active_count() != 3: - printerr("FAIL: 经验增加时未能通过 net_world 生成经验光球!实际数量: ", mgr.active_count()) + if mgr.active_count() != 0: + printerr("FAIL: POINT_EXP 不应重复生成经验光球,实际数量: ", mgr.active_count()) fails += 1 mount.queue_free() diff --git a/project/test_wasd_steering_parity.gd b/project/test_wasd_steering_parity.gd index b115c794..7906bf30 100644 --- a/project/test_wasd_steering_parity.gd +++ b/project/test_wasd_steering_parity.gd @@ -34,6 +34,34 @@ func _run() -> void: pc.player = player pc.rotation_speed_deg = 1200.0 + # ------------------------------------------------------------------------- + # Test 0: 40250 按键事件状态,不继承进入游戏前的系统按键 + # ------------------------------------------------------------------------- + print("\n--- Test 0: 进入游戏按键状态与 W/S 优先级 ---") + pc.stop() + _ck(pc._wasd() == Vector2.ZERO, "fresh/stop state cannot auto-move on game entry") + var w_down := InputEventKey.new() + w_down.keycode = KEY_W + w_down.pressed = true + pc.active = false + pc._unhandled_input(w_down) + pc.active = true + _ck(pc._wasd() == Vector2.ZERO, "W pressed during loading is not inherited by the game phase") + pc._unhandled_input(w_down) + _ck(pc._wasd() == Vector2(0, -1), "W KeyDown starts forward movement") + var s_down := InputEventKey.new() + s_down.keycode = KEY_S + s_down.pressed = true + pc._unhandled_input(s_down) + _ck(pc._wasd() == Vector2(0, -1), "W keeps priority while W and S overlap") + var w_up := InputEventKey.new() + w_up.keycode = KEY_W + w_up.pressed = false + pc._input(w_up) + _ck(pc._wasd() == Vector2(0, 1), "releasing W switches cleanly to held S") + pc.stop() + _ck(pc._wasd() == Vector2.ZERO, "stop clears all latched direction keys") + # ------------------------------------------------------------------------- # Test 1: 边界跨越平滑度与无翻跟头(X/Z 严格为 0) # ------------------------------------------------------------------------- diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index ddd08f25..36f94213 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -259,9 +259,9 @@ func set_motion_id(motion: int, target_race: int = -1) -> bool: # §3.7 CActorInstance::__SetMotion(SSetMotionData{ MAKE_MOTION_KEY(mode, index), fSpeedRatio }): # 攻击段动作按武器动作模式目录绑定(NORMAL_ATTACK -> attack(_1).msa,COMBO_ATTACK_N -> combo_0N.msa), # 模式目录缺该段时退回 general 的 attack(_1).msa(GENERAL 模式的 COMBO_ATTACK_* 也注册这两份)。 -func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void: +func play_attack_motion(mode: int, index: int, speed_ratio: float) -> bool: if anim == null or motion_dir == "": - return + return false var mode_dir := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(mode, "general"))) var names: Array = ["attack", "attack_1"] if index > MOTION_NORMAL_ATTACK: @@ -270,8 +270,9 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void: if msa == "": msa = _pick_msa(motion_dir, ["attack", "attack_1"]) if msa == "": - return + return false _bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0) + return true # 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310) # 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。 diff --git a/project/ui/view_equipment_ui.gd b/project/ui/view_equipment_ui.gd index 4be42077..a4f001b2 100644 --- a/project/ui/view_equipment_ui.gd +++ b/project/ui/view_equipment_ui.gd @@ -1,24 +1,35 @@ -# ViewEquipmentUI — GC_VIEW_EQUIP 的装备查看窗。 -# 服务器推送某个角色的 11 个传统可见装备位后自动刷新并置顶。 +# ViewEquipmentUI — GC_VIEW_EQUIP 的 40250 EquipmentDialog 复刻。 +# +# 参考:40250/root/uiequipmentdialog.py + uiscript/equipmentdialog.py。 +# 原版不是文字列表:固定 180x230 的装备槽底图承载 11 个装备位;鼠标进入 +# 一个非空槽位时,由同一 ItemToolTip.AddItemData 显示属性面板,离开立即隐藏。 extends Node -const WEAR_NAMES := ["身体", "头部", "鞋子", "护腕", "武器", "项链", "耳环", "盾牌", "饰品 1", "饰品 2", "箭矢"] -const ItemTooltip = preload("res://ui/item_tooltip.gd") +const UiAssets = preload("res://ui/ui_assets.gd") + +# 40250 EquipmentSlot:index, x, y, width, height(相对 equipment_bg)。 +const SLOT_RECTS := { + 0: Rect2(39, 37, 32, 64), 1: Rect2(39, 2, 32, 32), + 2: Rect2(39, 145, 32, 32), 3: Rect2(75, 67, 32, 32), + 4: Rect2(3, 3, 32, 96), 5: Rect2(114, 67, 32, 32), + 6: Rect2(114, 35, 32, 32), 7: Rect2(2, 145, 32, 32), + 8: Rect2(75, 145, 32, 32), 9: Rect2(114, 2, 32, 32), + 10: Rect2(75, 35, 32, 32), +} +const EQUIPMENT_BG := "d:/ymir work/ui/equipment_bg_without_ring.tga" var client: Node var ui: CanvasLayer var proto: Node var item_list: RefCounted -var _tooltip_builder: RefCounted var _window: Control +var _slots: Dictionary = {} # wear -> Panel func setup(m2client: Node, manager: CanvasLayer, proto_node: Node = null, il: RefCounted = null) -> void: client = m2client ui = manager proto = proto_node item_list = il - _tooltip_builder = ItemTooltip.new() - _tooltip_builder.setup(AssetRoot.path() if ClassDB.class_exists("AssetRoot") else "", "en", proto) if client.has_signal("view_equipment"): client.view_equipment.connect(show_for) @@ -29,6 +40,7 @@ func show_for(vid: int) -> void: if equipment.is_empty(): return if is_instance_valid(_window): + _hide_item_tooltip() ui.close(_window) _window = _build_window(vid, equipment) ui.open(_window) @@ -36,75 +48,111 @@ func show_for(vid: int) -> void: func _build_window(vid: int, equipment: Array) -> Control: var win := Panel.new() win.name = "ViewEquipment" - win.position = Vector2(1280, 180) - win.size = Vector2(280, 390) + win.position = Vector2(870, 300) + win.size = Vector2(180, 230) var bg := StyleBoxFlat.new() bg.bg_color = Color(0.05, 0.06, 0.09, 0.94) bg.border_color = Color(0.65, 0.5, 0.2, 0.9) bg.set_border_width_all(1) - bg.set_corner_radius_all(5) win.add_theme_stylebox_override("panel", bg) var title := Label.new() - title.position = Vector2(12, 8) - title.size = Vector2(220, 26) + title.position = Vector2(8, 4) + title.size = Vector2(142, 26) title.text = "%s 的装备" % _character_name(vid) - title.add_theme_font_size_override("font_size", 16) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 13) win.add_child(title) var close := Button.new() close.text = "×" - close.position = Vector2(238, 4) - close.size = Vector2(34, 30) + close.position = Vector2(150, 3) + close.size = Vector2(25, 25) close.pressed.connect(func(): if is_instance_valid(_window): + _hide_item_tooltip() ui.close(_window) _window = null) win.add_child(close) - var list := VBoxContainer.new() - list.position = Vector2(12, 42) - list.size = Vector2(256, 336) - list.add_theme_constant_override("separation", 3) - win.add_child(list) - for d in equipment: - var wear := int(d.get("wear", 0)) - var line := Label.new() - line.text = "%s: %s" % [_wear_name(wear), _item_name(int(d.get("vnum", 0)), int(d.get("count", 0)))] - line.tooltip_text = _item_tooltip(d) - line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - list.add_child(line) + var equipment_bg := TextureRect.new() + equipment_bg.name = "EquipmentBaseImage" + equipment_bg.position = Vector2(15, 35) + equipment_bg.size = Vector2(150, 182) + equipment_bg.texture = UiAssets.load_tex(AssetRoot.path(), EQUIPMENT_BG) + equipment_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + equipment_bg.stretch_mode = TextureRect.STRETCH_SCALE + equipment_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + win.add_child(equipment_bg) + + _slots.clear() + var by_wear := {} + for d: Dictionary in equipment: + by_wear[int(d.get("wear", -1))] = d + for wear in SLOT_RECTS: + var slot := _make_slot(int(wear), SLOT_RECTS[wear], by_wear.get(wear, {})) + equipment_bg.add_child(slot) + _slots[int(wear)] = slot return win +func _make_slot(wear: int, rect: Rect2, data: Dictionary) -> Panel: + var slot := Panel.new() + slot.name = "slot_%d" % wear + slot.position = rect.position + Vector2(3, 3) # 40250 EquipmentSlot 的偏移。 + slot.size = rect.size + slot.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + slot.set_meta("item", data.duplicate(true)) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.03, 0.04, 0.06, 0.35) + style.border_color = Color(0.52, 0.44, 0.25, 0.70) + style.set_border_width_all(1) + slot.add_theme_stylebox_override("panel", style) + var vnum := int(data.get("vnum", 0)) + if vnum > 0: + var icon := _item_icon(vnum) + if icon: + var image := TextureRect.new() + image.texture = icon + image.set_anchors_preset(Control.PRESET_FULL_RECT) + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + image.mouse_filter = Control.MOUSE_FILTER_IGNORE + slot.add_child(image) + var count := int(data.get("count", 1)) + if count > 1: + var count_label := Label.new() + count_label.text = str(count) + count_label.position = Vector2(1, rect.size.y - 15) + count_label.add_theme_font_size_override("font_size", 10) + count_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + slot.add_child(count_label) + slot.mouse_entered.connect(func(): _on_slot_over_in(wear)) + slot.mouse_exited.connect(_hide_item_tooltip) + return slot + +func _on_slot_over_in(wear: int) -> void: + var slot: Panel = _slots.get(wear, null) + if slot == null: + return + var data: Dictionary = slot.get_meta("item", {}) + var vnum := int(data.get("vnum", 0)) + if vnum <= 0: + return + # 对应 EquipmentDialog.OverInItem:一个共享 ItemToolTip,先清旧内容再 AddItemData。 + var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} + if ui and ui.has_method("show_item_tooltip"): + ui.show_item_tooltip(vnum, int(data.get("count", 1)), proto_data, data, {}, AssetRoot.path(), proto, item_list) + +func _hide_item_tooltip() -> void: + if ui and ui.has_method("hide_item_tooltip"): + ui.hide_item_tooltip() + +func _item_icon(vnum: int) -> Texture2D: + var path: String = item_list.icon(vnum) if item_list and item_list.has(vnum) else "icon/item/%05d.tga" % ((vnum / 10) * 10) + return UiAssets.load_tex(AssetRoot.path(), path) + func _character_name(vid: int) -> String: if client.has_method("get_entity"): var entity: Dictionary = client.get_entity(vid) if not entity.is_empty() and not String(entity.get("name", "")).is_empty(): return String(entity["name"]) return "角色 #%d" % vid - -func _wear_name(wear: int) -> String: - return WEAR_NAMES[wear] if wear >= 0 and wear < WEAR_NAMES.size() else "装备 %d" % wear - -func _item_name(vnum: int, count: int) -> String: - if vnum == 0: - return "(空)" - var name := "物品 #%d" % vnum - if proto: - var pd: Dictionary = proto.item(vnum) - if not pd.is_empty(): - name = String(pd.get("locale_name", pd.get("name", name))) - elif item_list and item_list.has(vnum): - name = item_list.type_of(vnum) - return "%s x%d" % [name, count] if count > 1 else name - -func _item_tooltip(d: Dictionary) -> String: - var vnum := int(d.get("vnum", 0)) - if _tooltip_builder and proto and proto.has_method("item"): - return _tooltip_builder.format(vnum, int(d.get("count", 1)), proto.item(vnum), d) - var attrs: Array = d.get("attrs", []) - if attrs.is_empty(): - return "" - var lines := [] - for attr in attrs: - lines.append("属性 %d: %+d" % [int(attr.get("type", 0)), int(attr.get("value", 0))]) - return "\n".join(lines) diff --git a/project/view_equipment_ui_test.gd b/project/view_equipment_ui_test.gd new file mode 100644 index 00000000..3b34fad2 --- /dev/null +++ b/project/view_equipment_ui_test.gd @@ -0,0 +1,65 @@ +# 40250 EquipmentDialog:槽位布局与统一属性面板回归测试。 +extends SceneTree + +const ViewEquipmentUI = preload("res://ui/view_equipment_ui.gd") +const UiManager = preload("res://ui/ui_manager.gd") + +class MockClient extends Node: + var equipment := [] + func get_view_equipment(_vid: int) -> Array: return equipment + +class MockProto extends Node: + func item(vnum: int) -> Dictionary: + if vnum != 19: + return {} + return { + "vnum": 19, "locale_name": "战斗剑 +9", "type": 1, + "values": [0, 0, 0, 10, 20, 0], + } + +var failures := 0 + +func _check(ok: bool, message: String) -> void: + if ok: + print("PASS: " + message) + else: + failures += 1 + printerr("FAIL: " + message) + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var ui := UiManager.new() + get_root().add_child(ui) + var client := MockClient.new() + client.equipment.resize(11) + for wear in 11: + client.equipment[wear] = {"wear": wear, "vnum": 0} + client.equipment[4] = {"wear": 4, "vnum": 19, "count": 1, + "sockets": [0, 0, 0], "attrs": [{"type": 5, "value": 12}]} + var view := ViewEquipmentUI.new() + get_root().add_child(view) + view.setup(client, ui, MockProto.new()) + view.show_for(1001) + await process_frame + + var dialog: Control = ui.find_child("ViewEquipment", true, false) + _check(dialog != null and dialog.size == Vector2(180, 230), "uses the 40250 180x230 dialog") + _check(view._slots.size() == 11, "creates all 11 legacy EquipmentSlot positions") + var weapon: Panel = view._slots.get(4) + _check(weapon != null and weapon.position == Vector2(6, 6) and weapon.size == Vector2(32, 96), + "weapon slot matches 40250 index 4 rectangle") + _check(weapon.tooltip_text.is_empty(), "slot has no native Godot tooltip panel") + view._on_slot_over_in(4) + await process_frame + var tooltip: Control = ui.get_item_tooltip_view(AssetRoot.path(), view.proto, null) + _check(tooltip.visible, "hover opens the shared 40250 ItemTooltipView") + _check(ui.find_children("ItemTooltipView", "", true, false).size() == 1, + "only one item attribute panel exists") + view._hide_item_tooltip() + _check(not tooltip.visible, "leaving the slot hides the shared attribute panel") + + if failures == 0: + print("ALL TESTS PASSED") + quit(failures)