update renderer and web viewer to correctly cut map and show caps (#1261)
This commit is contained in:
+890
-156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build map/mode info JSON for tank missions with planes + capture zones.
|
||||
|
||||
Output shape:
|
||||
{
|
||||
"<map_name>": {
|
||||
"<mode>": [
|
||||
{
|
||||
"info": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BOTS_DIR = SCRIPT_DIR.parent.parent
|
||||
|
||||
DATAMINE_LEVELS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "aces.vromfs.bin_u" / "levels"
|
||||
DATAMINE_MISSIONS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "mis.vromfs.bin_u"
|
||||
MISSION_ROOT = DATAMINE_MISSIONS_DIR / "gamedata" / "missions" / "cta" / "tanks"
|
||||
|
||||
SHARED_LEVELS_DIR = BOTS_DIR / "SHARED" / "MAPS" / "LEVELS"
|
||||
SHARED_MISSIONS_DIR = SHARED_LEVELS_DIR / "MISSIONS"
|
||||
|
||||
SKIP_PARTS = {"mainareas", "battleareas", "spawnareas"}
|
||||
|
||||
|
||||
def _clean_map_key(raw: str) -> str:
|
||||
key = Path(raw).name.strip().rstrip("*")
|
||||
if key.endswith(".png"):
|
||||
key = key[:-4]
|
||||
return key
|
||||
|
||||
|
||||
def _normalize_rel(path: str) -> str:
|
||||
rel = str(path or "").strip().replace("\\", "/").lstrip("/")
|
||||
if rel[:9].lower() == "gamedata/":
|
||||
rel = "gamedata/" + rel[9:]
|
||||
return rel.lower()
|
||||
|
||||
|
||||
def _load_json(path: Path, cache: dict[Path, Any]) -> Any:
|
||||
if path in cache:
|
||||
return cache[path]
|
||||
try:
|
||||
cache[path] = json.loads(path.read_text())
|
||||
except Exception:
|
||||
cache[path] = None
|
||||
return cache[path]
|
||||
|
||||
|
||||
def _repo_rel(path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(BOTS_DIR.resolve())).replace("\\", "/")
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _datamine_level_path(level_path: str) -> str:
|
||||
rel = str(level_path or "").replace("\\", "/").lstrip("/")
|
||||
if rel.lower().startswith("levels/"):
|
||||
return f"War-Thunder-Datamine/aces.vromfs.bin_u/{rel}"
|
||||
return f"War-Thunder-Datamine/{rel}" if rel else rel
|
||||
|
||||
|
||||
def _resolve_mission_file(rel: str) -> Path | None:
|
||||
norm = _normalize_rel(rel)
|
||||
if not norm:
|
||||
return None
|
||||
for root in (SHARED_MISSIONS_DIR, DATAMINE_MISSIONS_DIR):
|
||||
raw = root / norm
|
||||
if raw.suffix.lower() == ".blk":
|
||||
cands = [raw.with_suffix(".blkx"), raw]
|
||||
elif raw.suffix.lower() == ".blkx":
|
||||
cands = [raw, raw.with_suffix(".blk")]
|
||||
else:
|
||||
cands = [raw, raw.with_suffix(".blkx"), raw.with_suffix(".blk")]
|
||||
for p in cands:
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _import_paths(mission_def: dict) -> list[str]:
|
||||
imports = mission_def.get("imports")
|
||||
if not isinstance(imports, dict):
|
||||
return []
|
||||
import_record = imports.get("import_record")
|
||||
records = import_record if isinstance(import_record, list) else [import_record]
|
||||
out: list[str] = []
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
f = rec.get("file")
|
||||
if isinstance(f, str) and f.strip():
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
def _collect_defs(root_file: Path, cache: dict[Path, Any]) -> tuple[dict[str, dict], list[str], list[str]]:
|
||||
root_def = _load_json(root_file, cache)
|
||||
if not isinstance(root_def, dict):
|
||||
return {}, [], []
|
||||
|
||||
areas: dict[str, dict] = {}
|
||||
files_used: list[str] = []
|
||||
imports_used: list[str] = []
|
||||
queue: list[Path] = [root_file]
|
||||
seen: set[Path] = set()
|
||||
|
||||
while queue:
|
||||
cur_file = queue.pop(0).resolve()
|
||||
if cur_file in seen:
|
||||
continue
|
||||
seen.add(cur_file)
|
||||
files_used.append(str(cur_file))
|
||||
|
||||
cur_def = _load_json(cur_file, cache)
|
||||
if not isinstance(cur_def, dict):
|
||||
continue
|
||||
|
||||
cur_areas = cur_def.get("areas")
|
||||
if isinstance(cur_areas, dict):
|
||||
for name, area in cur_areas.items():
|
||||
if isinstance(name, str) and isinstance(area, dict) and name not in areas:
|
||||
areas[name] = area
|
||||
|
||||
for rel in _import_paths(cur_def):
|
||||
imports_used.append(_normalize_rel(rel))
|
||||
p = _resolve_mission_file(rel)
|
||||
if p is not None:
|
||||
queue.append(p)
|
||||
|
||||
return areas, files_used, sorted(set(imports_used))
|
||||
|
||||
|
||||
def _radius_from_tm(tm: list) -> float:
|
||||
try:
|
||||
ax = float(tm[0][0])
|
||||
az = float(tm[0][2])
|
||||
bx = float(tm[2][0])
|
||||
bz = float(tm[2][2])
|
||||
ra = (ax * ax + az * az) ** 0.5
|
||||
rb = (bx * bx + bz * bz) ** 0.5
|
||||
return max(ra, rb)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _capture_prefixes(mission_type: str, mode_key: str) -> list[str]:
|
||||
m = (mission_type or "").lower()
|
||||
k = (mode_key or "").lower()
|
||||
if "conq" in m or "conq" in k:
|
||||
return ["conq_capture_area_"]
|
||||
if "dom" in m or "dom" in k:
|
||||
return ["dom_capture_area_"]
|
||||
if "bttl" in m or "bttl" in k:
|
||||
return ["bttl_t1_capture_area_", "bttl_t2_capture_area_"]
|
||||
return ["capture_area_"]
|
||||
|
||||
|
||||
def _capture_sort(name: str) -> tuple[int, str]:
|
||||
digits = "".join(ch for ch in name if ch.isdigit())
|
||||
return (int(digits) if digits else 999, name)
|
||||
|
||||
|
||||
def _pick_capture_areas(areas: dict[str, dict], mission_type: str, mode_key: str) -> list[dict]:
|
||||
prefixes = _capture_prefixes(mission_type, mode_key)
|
||||
names: list[str] = []
|
||||
for name, area in areas.items():
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
lname = name.lower()
|
||||
if "capture_area" not in lname or lname.startswith("briefing_"):
|
||||
continue
|
||||
if not any(lname.startswith(p) for p in prefixes):
|
||||
continue
|
||||
names.append(name)
|
||||
|
||||
if not names:
|
||||
return []
|
||||
|
||||
suffix_prio = ["_arcade", "_realistic", "_hardcore", "_simulator", ""]
|
||||
groups: dict[str, dict[str, str]] = {}
|
||||
for name in names:
|
||||
lname = name.lower()
|
||||
suffix = ""
|
||||
base = lname
|
||||
for sfx in suffix_prio[:-1]:
|
||||
if lname.endswith(sfx):
|
||||
suffix = sfx
|
||||
base = lname[:-len(sfx)]
|
||||
break
|
||||
groups.setdefault(base, {})[suffix] = name
|
||||
|
||||
chosen: list[str] = []
|
||||
for base in sorted(groups.keys(), key=_capture_sort):
|
||||
variants = groups[base]
|
||||
for sfx in suffix_prio:
|
||||
if sfx in variants:
|
||||
chosen.append(variants[sfx])
|
||||
break
|
||||
|
||||
out: list[dict] = []
|
||||
for name in chosen:
|
||||
area = areas.get(name)
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
tm = area.get("tm")
|
||||
if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list):
|
||||
continue
|
||||
try:
|
||||
cx = float(tm[3][0])
|
||||
cz = float(tm[3][2])
|
||||
except Exception:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"type": str(area.get("type", "")),
|
||||
"x": cx,
|
||||
"z": cz,
|
||||
"radius": _radius_from_tm(tm),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _battle_areas(areas: dict[str, dict]) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for name, area in areas.items():
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
lname = name.lower()
|
||||
if "battle_area" not in lname or lname.startswith("briefing_"):
|
||||
continue
|
||||
tm = area.get("tm")
|
||||
if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list):
|
||||
continue
|
||||
try:
|
||||
cx = float(tm[3][0])
|
||||
cz = float(tm[3][2])
|
||||
except Exception:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"type": str(area.get("type", "")),
|
||||
"x": cx,
|
||||
"z": cz,
|
||||
"radius": _radius_from_tm(tm),
|
||||
})
|
||||
out.sort(key=lambda x: x["name"])
|
||||
return out
|
||||
|
||||
|
||||
def _level_info(level_stem: str, cache: dict[Path, Any]) -> dict:
|
||||
cands = [
|
||||
DATAMINE_LEVELS_DIR / f"{level_stem}.blkx",
|
||||
SHARED_LEVELS_DIR / f"{level_stem}.blkx",
|
||||
SHARED_LEVELS_DIR / "DATAMINE" / f"{level_stem}.blkx",
|
||||
]
|
||||
for p in cands:
|
||||
if not p.exists():
|
||||
continue
|
||||
data = _load_json(p, cache)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
return {
|
||||
"file": _repo_rel(p),
|
||||
"tankMapCoord0": data.get("tankMapCoord0"),
|
||||
"tankMapCoord1": data.get("tankMapCoord1"),
|
||||
"aiTanksMapCoord0": data.get("aiTanksMapCoord0"),
|
||||
"aiTanksMapCoord1": data.get("aiTanksMapCoord1"),
|
||||
"customLevelTankMap": data.get("customLevelTankMap"),
|
||||
"customLevelMap": data.get("customLevelMap"),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def _has_planes(allowed: dict | None, imports_used: list[str]) -> bool:
|
||||
if isinstance(allowed, dict):
|
||||
val = allowed.get("isAirplanesAllowed")
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
return any("air_spawns" in p for p in imports_used)
|
||||
|
||||
|
||||
def _has_tanks(allowed: dict | None) -> bool:
|
||||
if isinstance(allowed, dict):
|
||||
val = allowed.get("isTanksAllowed")
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
return True
|
||||
|
||||
|
||||
def _mode_key(mission_cfg: dict, mission_file: Path) -> str:
|
||||
postfix = mission_cfg.get("postfix")
|
||||
if isinstance(postfix, str) and postfix.strip():
|
||||
return postfix.lstrip("_")
|
||||
mtype = mission_cfg.get("type")
|
||||
if isinstance(mtype, str) and mtype.strip():
|
||||
return mtype
|
||||
return mission_file.stem
|
||||
|
||||
|
||||
def should_skip_mission_file(path: Path) -> bool:
|
||||
if path.name.startswith("template_"):
|
||||
return True
|
||||
parts = {p.lower() for p in path.parts}
|
||||
if SKIP_PARTS & parts:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_index() -> dict[str, dict[str, list[dict[str, Any]]]]:
|
||||
cache: dict[Path, Any] = {}
|
||||
output: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
|
||||
for mission_file in sorted(MISSION_ROOT.rglob("*.blkx")):
|
||||
if should_skip_mission_file(mission_file):
|
||||
continue
|
||||
|
||||
mission_def = _load_json(mission_file, cache)
|
||||
if not isinstance(mission_def, dict):
|
||||
continue
|
||||
mission_settings = mission_def.get("mission_settings")
|
||||
if not isinstance(mission_settings, dict):
|
||||
continue
|
||||
mission_cfg = mission_settings.get("mission")
|
||||
if not isinstance(mission_cfg, dict):
|
||||
continue
|
||||
|
||||
level_path = mission_cfg.get("level", "")
|
||||
if not isinstance(level_path, str) or not level_path.strip():
|
||||
continue
|
||||
level_stem = Path(level_path).stem
|
||||
mode = _mode_key(mission_cfg, mission_file)
|
||||
mission_type = str(mission_cfg.get("type", ""))
|
||||
allowed = mission_cfg.get("allowedUnitTypes")
|
||||
|
||||
areas, files_used, imports_used = _collect_defs(mission_file, cache)
|
||||
captures = _pick_capture_areas(areas, mission_type, mode)
|
||||
if not captures:
|
||||
continue
|
||||
|
||||
has_tanks = _has_tanks(allowed if isinstance(allowed, dict) else None)
|
||||
has_planes = _has_planes(allowed if isinstance(allowed, dict) else None, imports_used)
|
||||
if not (has_tanks and has_planes):
|
||||
continue
|
||||
|
||||
battle_areas = _battle_areas(areas)
|
||||
info = {
|
||||
"mission_file": _repo_rel(mission_file),
|
||||
"level_path": _datamine_level_path(level_path),
|
||||
"mission_type": mission_type,
|
||||
"mode_postfix": mission_cfg.get("postfix", ""),
|
||||
"allowed_unit_types": allowed if isinstance(allowed, dict) else {},
|
||||
"useAlternativeMapCoord": mission_cfg.get("useAlternativeMapCoord"),
|
||||
"capture_areas": captures,
|
||||
"battle_areas": battle_areas,
|
||||
"level_def": _level_info(level_stem, cache),
|
||||
"imports_count": len(imports_used),
|
||||
"files_used_count": len(files_used),
|
||||
}
|
||||
|
||||
output.setdefault(level_stem, {}).setdefault(mode, []).append({"info": info})
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build map_name -> mode -> [info] JSON")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=SCRIPT_DIR.parent / "map_mode_info.json",
|
||||
help="Output JSON path",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = build_index()
|
||||
args.out.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
maps = len(data)
|
||||
modes = sum(len(v) for v in data.values())
|
||||
entries = sum(len(items) for v in data.values() for items in v.values())
|
||||
print(f"Wrote {args.out} (maps={maps}, modes={modes}, entries={entries})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+149
-70
@@ -12,6 +12,9 @@ const RC = {
|
||||
WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,',
|
||||
DOT_R: 5, AIR_R: 4, DRONE_R: 3,
|
||||
};
|
||||
const RC_CAP_STROKE_PX = 3;
|
||||
const RC_CAP_ICON_ALPHA = 0.35;
|
||||
const RC_CAP_ICON_MIN_SIZE = 10;
|
||||
|
||||
function replayT(key) {
|
||||
return (window.__t && window.__t(key)) || key;
|
||||
@@ -39,6 +42,7 @@ class ReplayCanvas {
|
||||
this._groundCoords = data.levelCoords;
|
||||
this._airCoords = data.mapCoords || null;
|
||||
this._fullMapLevel = data.fullMapLevel || null;
|
||||
this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : [];
|
||||
this._mode = 'ground';
|
||||
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
||||
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
||||
@@ -47,7 +51,7 @@ class ReplayCanvas {
|
||||
this.z0 = data.levelCoords.z0;
|
||||
this.xRange = data.levelCoords.x1 - data.levelCoords.x0;
|
||||
this.zRange = data.levelCoords.z1 - data.levelCoords.z0;
|
||||
// Default map source rect (full image) — overwritten by _computeAutocrop
|
||||
// Source rect for minimap image (full image by default)
|
||||
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
||||
|
||||
this.players = {};
|
||||
@@ -74,9 +78,6 @@ class ReplayCanvas {
|
||||
});
|
||||
}
|
||||
|
||||
// Zoom into the area players actually use (like the video maker autocrop)
|
||||
this._computeAutocrop();
|
||||
|
||||
// Pre-compute deaths
|
||||
this._computeDeaths();
|
||||
this.currentTime = this.tStart;
|
||||
@@ -110,70 +111,6 @@ class ReplayCanvas {
|
||||
];
|
||||
}
|
||||
|
||||
/** Recompute x0/z0/xRange/zRange to zoom into entity activity.
|
||||
* In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */
|
||||
_computeAutocrop() {
|
||||
const origX0 = this.x0, origZ0 = this.z0;
|
||||
const origXR = this.xRange, origZR = this.zRange;
|
||||
|
||||
const airMode = this._mode === 'air';
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minZ = Infinity, maxZ = -Infinity;
|
||||
for (const ent of this.entities) {
|
||||
if (airMode) {
|
||||
if (ent.type !== 'aircraft' && ent.type !== 'drone') continue;
|
||||
} else {
|
||||
if (ent.type !== 'ground') continue;
|
||||
}
|
||||
const { positions } = ent;
|
||||
for (let i = 0; i < positions.length; i += 2) {
|
||||
const x = positions[i], z = positions[i + 1];
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (z < minZ) minZ = z;
|
||||
if (z > maxZ) maxZ = z;
|
||||
}
|
||||
}
|
||||
if (!isFinite(minX)) return; // no relevant positions — keep full map
|
||||
|
||||
// Padding: 10% of span or 50 world units, whichever is larger
|
||||
const span = Math.max(maxX - minX, maxZ - minZ);
|
||||
const pad = Math.max(50, span * 0.10);
|
||||
minX -= pad; maxX += pad;
|
||||
minZ -= pad; maxZ += pad;
|
||||
|
||||
// Expand to square, with a minimum of 15% of the map range
|
||||
const minSide = Math.max(origXR, origZR) * 0.15;
|
||||
let side = Math.max(maxX - minX, maxZ - minZ, minSide);
|
||||
const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2;
|
||||
minX = midX - side / 2; maxX = midX + side / 2;
|
||||
minZ = midZ - side / 2; maxZ = midZ + side / 2;
|
||||
|
||||
// Clamp to LevelDef bounds (shift first, then hard-clamp)
|
||||
const x1 = origX0 + origXR, z1 = origZ0 + origZR;
|
||||
if (minX < origX0) { maxX += origX0 - minX; minX = origX0; }
|
||||
if (maxX > x1) { minX -= maxX - x1; maxX = x1; }
|
||||
if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; }
|
||||
if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; }
|
||||
minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1);
|
||||
minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1);
|
||||
|
||||
// Fractional source rect for the minimap image
|
||||
// Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0)
|
||||
this._mapSrc = {
|
||||
u: (minX - origX0) / origXR,
|
||||
v: (z1 - maxZ) / origZR,
|
||||
w: (maxX - minX) / origXR,
|
||||
h: (maxZ - minZ) / origZR,
|
||||
};
|
||||
|
||||
// Apply new bounds — worldToPixel will now map this sub-region to the full canvas
|
||||
this.x0 = minX;
|
||||
this.z0 = minZ;
|
||||
this.xRange = maxX - minX;
|
||||
this.zRange = maxZ - minZ;
|
||||
}
|
||||
|
||||
getPositionAtTime(entity, time) {
|
||||
const { times, positions } = entity;
|
||||
if (time < times[0] || time > times[times.length - 1]) return null;
|
||||
@@ -533,6 +470,140 @@ class ReplayCanvas {
|
||||
ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh);
|
||||
}
|
||||
|
||||
_capOutlinePoints(cap) {
|
||||
const tm = cap?.tm;
|
||||
if (!tm || !Array.isArray(tm.a0) || !Array.isArray(tm.a2) || !Array.isArray(tm.center)) return [];
|
||||
const ax0 = Number(tm.a0[0]), az0 = Number(tm.a0[2]);
|
||||
const ax2 = Number(tm.a2[0]), az2 = Number(tm.a2[2]);
|
||||
const cx = Number(tm.center[0]), cz = Number(tm.center[2]);
|
||||
if (!Number.isFinite(ax0) || !Number.isFinite(az0) || !Number.isFinite(ax2) || !Number.isFinite(az2)
|
||||
|| !Number.isFinite(cx) || !Number.isFinite(cz)) return [];
|
||||
|
||||
const capType = String(cap?.type || '').toLowerCase();
|
||||
const points = [];
|
||||
if (capType === 'sphere' || capType === 'cylinder') {
|
||||
const steps = 64;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = (2 * Math.PI * i) / steps;
|
||||
const wx = cx + Math.cos(t) * ax0 + Math.sin(t) * ax2;
|
||||
const wz = cz + Math.cos(t) * az0 + Math.sin(t) * az2;
|
||||
points.push(this.worldToPixel(wx, wz));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
if (capType === 'box') {
|
||||
const corners = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
|
||||
for (const [sx, sz] of corners) {
|
||||
const wx = cx + sx * ax0 + sz * ax2;
|
||||
const wz = cz + sx * az0 + sz * az2;
|
||||
points.push(this.worldToPixel(wx, wz));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_pickContrastOutlineColor(points) {
|
||||
if (!points || points.length === 0) return '#fff';
|
||||
const W = this.canvasSize;
|
||||
const H = this.canvasSize;
|
||||
let lumaSum = 0;
|
||||
let n = 0;
|
||||
for (const [px, py] of points) {
|
||||
const x = Math.max(0, Math.min(W - 1, Math.round(px)));
|
||||
const y = Math.max(0, Math.min(H - 1, Math.round(py)));
|
||||
try {
|
||||
const d = this.mapCtx.getImageData(x, y, 1, 1).data;
|
||||
const luma = 0.2126 * d[0] + 0.7152 * d[1] + 0.0722 * d[2];
|
||||
lumaSum += luma;
|
||||
n++;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (n === 0) return '#fff';
|
||||
return (lumaSum / n) < 128 ? '#fff' : '#000';
|
||||
}
|
||||
|
||||
_capIconForLabel(label) {
|
||||
const key = String(label || '').toLowerCase();
|
||||
if (!this._capIconCache) return null;
|
||||
return this._capIconCache[`capture_${key}`] || this._capIconCache.cap_icon || null;
|
||||
}
|
||||
|
||||
_drawCaptureIcon(label, px, py, size) {
|
||||
const img = this._capIconForLabel(label);
|
||||
if (!img || !img.naturalWidth) return;
|
||||
const s = Math.max(RC_CAP_ICON_MIN_SIZE, Math.round(size));
|
||||
this.mapCtx.save();
|
||||
this.mapCtx.globalAlpha = RC_CAP_ICON_ALPHA;
|
||||
this.mapCtx.drawImage(img, px - s / 2, py - s / 2, s, s);
|
||||
this.mapCtx.restore();
|
||||
}
|
||||
|
||||
_drawCaptureAreasOnMap() {
|
||||
if (this._mode !== 'ground') return;
|
||||
if (!this.captureAreas || this.captureAreas.length === 0) return;
|
||||
|
||||
const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
for (let i = 0; i < this.captureAreas.length; i++) {
|
||||
const cap = this.captureAreas[i];
|
||||
const label = labels[i] || String(i + 1);
|
||||
const cx = Number(cap?.x || 0);
|
||||
const cz = Number(cap?.z || 0);
|
||||
const [px, py] = this.worldToPixel(cx, cz);
|
||||
const outlinePoints = this._capOutlinePoints(cap);
|
||||
|
||||
let iconSize = 18;
|
||||
if (outlinePoints.length >= 3) {
|
||||
const outline = this._pickContrastOutlineColor(outlinePoints);
|
||||
this.mapCtx.strokeStyle = outline;
|
||||
this.mapCtx.lineWidth = RC_CAP_STROKE_PX;
|
||||
this.mapCtx.beginPath();
|
||||
this.mapCtx.moveTo(outlinePoints[0][0], outlinePoints[0][1]);
|
||||
for (let p = 1; p < outlinePoints.length; p++) {
|
||||
this.mapCtx.lineTo(outlinePoints[p][0], outlinePoints[p][1]);
|
||||
}
|
||||
this.mapCtx.closePath();
|
||||
this.mapCtx.stroke();
|
||||
|
||||
let area2 = 0;
|
||||
const nPts = outlinePoints.length;
|
||||
for (let p = 0; p < nPts; p++) {
|
||||
const a = outlinePoints[p];
|
||||
const b = outlinePoints[(p + 1) % nPts];
|
||||
area2 += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
const capArea = Math.abs(area2) * 0.5;
|
||||
if (capArea > 0) {
|
||||
iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2))));
|
||||
}
|
||||
} else {
|
||||
const rr = Math.max(0, Number(cap?.radius || 0));
|
||||
let rp = Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5));
|
||||
rp = Math.max(8, rp);
|
||||
if (px < -rp || py < -rp || px > this.canvasSize + rp || py > this.canvasSize + rp) continue;
|
||||
|
||||
const ringSamples = [
|
||||
[px + rp, py], [px - rp, py], [px, py + rp], [px, py - rp],
|
||||
[px + Math.round(rp * 0.707), py + Math.round(rp * 0.707)],
|
||||
[px - Math.round(rp * 0.707), py + Math.round(rp * 0.707)],
|
||||
[px + Math.round(rp * 0.707), py - Math.round(rp * 0.707)],
|
||||
[px - Math.round(rp * 0.707), py - Math.round(rp * 0.707)],
|
||||
];
|
||||
const outline = this._pickContrastOutlineColor(ringSamples);
|
||||
this.mapCtx.strokeStyle = outline;
|
||||
this.mapCtx.lineWidth = RC_CAP_STROKE_PX;
|
||||
this.mapCtx.beginPath();
|
||||
this.mapCtx.arc(px, py, rp, 0, Math.PI * 2);
|
||||
this.mapCtx.stroke();
|
||||
const capArea = Math.PI * rp * rp;
|
||||
iconSize = Math.max(RC_CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(capArea / 2))));
|
||||
}
|
||||
this._drawCaptureIcon(label, px, py, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadEntityIcons() {
|
||||
this._iconCache = {};
|
||||
this._iconDebug = {};
|
||||
@@ -587,12 +658,21 @@ class ReplayCanvas {
|
||||
} else {
|
||||
this._airMapImg = null;
|
||||
}
|
||||
this._capIconCache = {
|
||||
cap_icon: await loadImg('/api/icons/type/cap_icon'),
|
||||
};
|
||||
const capLetters = new Set();
|
||||
for (let i = 0; i < this.captureAreas.length && i < 26; i++) capLetters.add(String.fromCharCode(97 + i));
|
||||
for (const letter of capLetters) {
|
||||
this._capIconCache[`capture_${letter}`] = await loadImg(`/api/icons/type/capture_${letter}`);
|
||||
}
|
||||
|
||||
this._drawMapToCanvas();
|
||||
}
|
||||
|
||||
_drawMapToCanvas() {
|
||||
const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg;
|
||||
this.mapCtx.clearRect(0, 0, this.canvasSize, this.canvasSize);
|
||||
if (!img) {
|
||||
this.mapCtx.fillStyle = '#111';
|
||||
this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize);
|
||||
@@ -602,6 +682,7 @@ class ReplayCanvas {
|
||||
const sx = u * img.naturalWidth, sy = v * img.naturalHeight;
|
||||
const sw = w * img.naturalWidth, sh = h * img.naturalHeight;
|
||||
this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize);
|
||||
this._drawCaptureAreasOnMap();
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
@@ -617,8 +698,6 @@ class ReplayCanvas {
|
||||
this.zRange = coords.z1 - coords.z0;
|
||||
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
||||
|
||||
// Recompute autocrop for the new entity filter
|
||||
this._computeAutocrop();
|
||||
// Redraw map background with new crop region
|
||||
this._drawMapToCanvas();
|
||||
// Recompute death positions in new coordinate space
|
||||
|
||||
Reference in New Issue
Block a user