// Pure 3D replay view. No DOM beyond its own ; driven entirely by an // external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine. // Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of // its own UI (labels, axes, guide lines, controls, hover/active-list panels). import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { OBJLoader } from 'three/addons/loaders/OBJLoader.js' const MODEL_PATH = '/models/t34/' const MINIMAP_URL = (level) => `/api/match/minimap/${level}` const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full` const WIN_COLOR = '#00c800' const LOSE_COLOR = '#dc1e1e' const DRONE_COLOR = '#cbd5e1' const DIRECTION_SAMPLE_MS = 700 const DIRECTION_BLEND = 0.22 const KILL_LINE_WINDOW_MS = 3000 const FLIP_MAP_TEXTURE_Z = true function coordsAreValid(c) { return c && Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) && Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) && Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1) } function normalizeCoords(c) { if (!coordsAreValid(c)) return null return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) } } function mapSourceRect(baseCoords, renderCoords) { const base = normalizeCoords(baseCoords) const render = normalizeCoords(renderCoords) if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 } const dx = base.x1 - base.x0 const dz = base.z1 - base.z0 const xLo = Math.min(render.x0, render.x1) const xHi = Math.max(render.x0, render.x1) const zLo = Math.min(render.z0, render.z1) const zHi = Math.max(render.z0, render.z1) const u0 = (xLo - base.x0) / dx const u1 = (xHi - base.x0) / dx const v0 = (zLo - base.z0) / dz const v1 = (zHi - base.z0) / dz const uMin = Math.max(0, Math.min(1, Math.min(u0, u1))) const uMax = Math.max(0, Math.min(1, Math.max(u0, u1))) const vMin = Math.max(0, Math.min(1, Math.min(v0, v1))) const vMax = Math.max(0, Math.min(1, Math.max(v0, v1))) const w = uMax - uMin const h = vMax - vMin if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 } return { u: uMin, v: 1 - vMax, w, h } } function fallbackBoundsCoords(bounds) { const pad = Math.max(bounds.planarSpan * 0.15, 250) return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad } } function catmullRom(a, b, c, d, t) { const t2 = t * t const t3 = t2 * t return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3) } class ReplayCanvas3D { constructor(container, data) { this.container = container this.data = data this.disposed = false this.currentT = 0 this.selectedPlayerId = null this.smoothByEntity = new Map() this.players = {} for (const p of data.players || []) this.players[p.id] = p this.teamWon = data.teamWon this.winnerSlot = Number(data.winnerSlot) || 0 this._buildEntities() this._buildCaptureModel() this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos) this.bounds = this._computeBounds() this.scale = 1200 / this.bounds.planarSpan this._initThree() this._mode = 'ground' this.mapInfo = this._resolveMapInfo('ground') this._buildScene() this._loadTankTemplate() this._loadMapTexture() this._animate = this._animate.bind(this) this._raf = requestAnimationFrame(this._animate) this.setTime(0) } // ---- data adaptation ----------------------------------------------------- _isWinner(entity) { if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon return entity.droneTeam === this.teamWon } _colorFor(entity) { if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR } _buildEntities() { this.entities = [] for (const e of this.data.entities || []) { if (!Array.isArray(e.path) || !e.path.length) continue const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) })) this.entities.push({ playerId: e.playerId, entityIndex: e.entityIndex, type: e.type, droneTeam: e.droneTeam, path, startT: path[0].t, endT: path[path.length - 1].t, }) } } _buildCaptureModel() { const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : [] const state = this.data.captureState || {} this.captureZones = areas.map((area, index) => { const letter = String.fromCharCode(65 + index) const cap = Array.isArray(state[letter]) ? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v)) : [] const center = area.tm?.center || [area.x, 0, area.z] return { key: letter, center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) }, radius: Math.max(1, Number(area.radius) || 1), cap, } }) } _computeBounds() { const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity } for (const e of this.entities) { for (const p of e.path) { b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x) b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y) b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z) } } if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 } b.centerX = (b.minX + b.maxX) / 2 b.centerZ = (b.minZ + b.maxZ) / 2 b.spanX = Math.max(1, b.maxX - b.minX) b.spanZ = Math.max(1, b.maxZ - b.minZ) b.planarSpan = Math.max(b.spanX, b.spanZ) return b } _resolveMapInfo(mode) { const level = this.data.mission?.level const full = this.data.fullMapLevel if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) { const coords = normalizeCoords(this.data.mapCoords) return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } } } const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds) const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords return { image: level ? MINIMAP_URL(level) : null, coords, baseCoords, sourceRect: mapSourceRect(baseCoords, coords), } } // ---- coordinate transforms ---------------------------------------------- _toWorld(p) { const b = this.bounds return new THREE.Vector3( (p.x - b.centerX) * this.scale, (p.y - b.minY) * this.scale, (p.z - b.centerZ) * this.scale, ) } _renderPoint(p) { if (!FLIP_MAP_TEXTURE_Z) return p const coords = normalizeCoords(this.mapInfo?.coords) if (!coords) return p return { ...p, z: coords.z0 + coords.z1 - p.z } } // ---- three.js setup ------------------------------------------------------ _initThree() { const canvas = document.createElement('canvas') canvas.className = 'rc3d-canvas' this.canvasEl = canvas this.container.appendChild(canvas) this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) this.renderer.setClearColor(0x0b0d10, 1) this.scene = new THREE.Scene() this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000) this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping = true this.controls.dampingFactor = 0.08 this.controls.screenSpacePanning = false this.controls.maxPolarAngle = Math.PI * 0.49 this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN this.root = new THREE.Group() this.mapGroup = new THREE.Group() this.pathGroup = new THREE.Group() this.zoneGroup = new THREE.Group() this.killLineGroup = new THREE.Group() this.markerGroup = new THREE.Group() this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup) this.scene.add(this.root) this.scene.add(new THREE.AmbientLight(0xffffff, 0.72)) const sun = new THREE.DirectionalLight(0xffffff, 1.1) sun.position.set(240, 500, 180) this.scene.add(sun) this.resize() } _worldMapExtents(coords = this.mapInfo?.coords) { const c = normalizeCoords(coords) if (!c) return null const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 }) const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 }) return { minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x), minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z), width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z), } } _resetCamera() { const ext = this._worldMapExtents() const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100) this.camera.position.set(span * 0.45, span * 0.55, span * 0.72) this.controls.target.set(0, 0, 0) this.controls.minDistance = Math.max(18, span * 0.025) this.controls.maxDistance = Math.max(320, span * 1.8) this.camera.far = Math.max(9000, span * 8) this.camera.updateProjectionMatrix() this.controls.update() } // ---- scene construction -------------------------------------------------- _buildScene() { this._clearGroup(this.mapGroup) this._clearGroup(this.pathGroup) this._clearGroup(this.markerGroup) this._clearGroup(this.zoneGroup) this._clearGroup(this.killLineGroup) this._buildMapPlane() this._buildCaptureZones() this._buildKillLines() this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e)) this._applyHighlight() this._resetCamera() } _clearGroup(group) { for (const child of [...group.children]) { group.remove(child) child.traverse?.((c) => { if (c.geometry) c.geometry.dispose() if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose()) }) } } _buildMapPlane() { const info = this.mapInfo if (!info || !coordsAreValid(info.coords)) return const x0 = Math.min(info.coords.x0, info.coords.x1) const x1 = Math.max(info.coords.x0, info.coords.x1) const z0 = Math.min(info.coords.z0, info.coords.z1) const z1 = Math.max(info.coords.z0, info.coords.z1) const width = Math.max(1, (x1 - x0) * this.scale) const depth = Math.max(1, (z1 - z0) * this.scale) const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 }) const geometry = new THREE.PlaneGeometry(width, depth, 1, 1) this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 }) geometry.rotateX(-Math.PI / 2) const material = new THREE.MeshBasicMaterial({ color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide, }) const plane = new THREE.Mesh(geometry, material) plane.position.set(center.x, -0.12, center.z) plane.renderOrder = -10 this.mapGroup.add(plane) this._mapMaterial = material } _applyPlaneUvs(geometry, rect) { const u0 = rect.u const u1 = rect.u + rect.w const vTop = rect.v const vBottom = rect.v + rect.h const uv = geometry.attributes.uv if (FLIP_MAP_TEXTURE_Z) { uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop) uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom) } else { uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom) uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop) } uv.needsUpdate = true } _loadMapTexture() { if (!this.mapInfo.image || !this._mapMaterial) return const material = this._mapMaterial new THREE.TextureLoader().load( this.mapInfo.image, (texture) => { if (this.disposed) { texture.dispose(); return } texture.colorSpace = THREE.SRGBColorSpace texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy() material.map = texture material.color.setHex(0xffffff) material.needsUpdate = true }, undefined, () => { material.color.setHex(0x1f2933); material.opacity = 0.6 }, ) } _makeVisualEntity(entity) { const color = this._colorFor(entity) const points = entity.path.map((p) => this._toWorld(this._renderPoint(p))) const lineGeometry = new THREE.BufferGeometry().setFromPoints(points) const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 }) const line = new THREE.Line(lineGeometry, lineMaterial) this.pathGroup.add(line) const radius = 4.4 let model if (this.unitTemplate && entity.type === 'ground') { model = this.unitTemplate.clone(true) model.traverse((child) => { if (!child.isMesh) return child.material = new THREE.MeshStandardMaterial({ color, roughness: 0.72, metalness: 0.06, flatShading: true, emissive: new THREE.Color(color).multiplyScalar(0.22), }) }) } else { const geo = new THREE.SphereGeometry(radius, 18, 12) const mat = new THREE.MeshStandardMaterial({ color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08, }) model = new THREE.Mesh(geo, mat) } const arrow = new THREE.ArrowHelper( new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0), radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9, ) arrow.line.material.transparent = true arrow.line.material.opacity = 0.78 arrow.cone.material.transparent = true arrow.cone.material.opacity = 0.95 const group = new THREE.Group() group.add(model, arrow) this.markerGroup.add(group) return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null } } async _loadTankTemplate() { try { const object = await new Promise((resolve, reject) => { const loader = new OBJLoader() loader.setPath(MODEL_PATH) loader.load('t_34_obj.obj', resolve, undefined, reject) }) if (this.disposed) return const planes = [] object.traverse((child) => { if (!child.isMesh) return child.castShadow = false child.receiveShadow = false if (/^Plane/i.test(child.name || '')) { planes.push(child); return } }) for (const plane of planes) plane.removeFromParent() const box = new THREE.Box3() object.traverse((child) => { if (child.isMesh) box.expandByObject(child) }) if (box.isEmpty()) box.setFromObject(object) const size = box.getSize(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z, 1) const scale = 70 / maxDim const center = box.getCenter(new THREE.Vector3()) const pivot = new THREE.Group() object.position.x -= center.x object.position.z -= center.z object.position.y -= box.min.y pivot.add(object) pivot.scale.setScalar(scale) this.unitTemplate = pivot // Rebuild ground entities now that the model is available. this._buildScene() this.setTime(this.currentT) } catch { // No model -> spheres are used; nothing else to do. } } // ---- capture zones ------------------------------------------------------- _buildCaptureZones() { for (const zone of this.captureZones) { const center = this._toWorld(this._renderPoint(zone.center)) const radius = Math.max(8, zone.radius * this.scale) const group = new THREE.Group() group.position.set(center.x, 1.05, center.z) const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96) ringGeo.rotateX(-Math.PI / 2) const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide, })) ring.renderOrder = 8 const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96) fillGeo.rotateX(-Math.PI / 2) const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({ color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide, })) fill.position.y = 0.02 fill.renderOrder = 7 group.add(fill, ring) group.userData = { zone, fill } this.zoneGroup.add(group) } } // Step interpolation, matching the 2D engine's _interpSeries(series, t, true). _captureValueAt(zone, t) { const cap = zone.cap if (!cap.length) return 0 if (t <= cap[0][0]) return cap[0][1] const last = cap[cap.length - 1] if (t >= last[0]) return last[1] for (let i = 1; i < cap.length; i++) { if (cap[i][0] >= t) return cap[i - 1][1] } return last[1] } _updateCaptureZones() { for (const group of this.zoneGroup.children) { const { zone, fill } = group.userData if (!zone || !fill) continue // Same logic as the 2D engine: owner is slot 2 if value>0 else slot 1, // coloured green when it matches the winning slot, red otherwise. const value = this._captureValueAt(zone, this.currentT) const frac = Math.min(1, Math.abs(value) / 100) if (frac <= 0.01) { fill.visible = false; continue } const ownerSlot = value > 0 ? 2 : 1 fill.visible = true fill.material.color.set(ownerSlot === this.winnerSlot ? WIN_COLOR : LOSE_COLOR) fill.material.opacity = 0.18 + frac * 0.34 const s = Math.sqrt(frac) fill.scale.set(s, s, 1) } } // ---- kill lines ---------------------------------------------------------- _buildKillLines() { this.killLines = [] for (const k of this.kills) { const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z })) const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z })) start.y += 12; end.y += 12 const vec = end.clone().sub(start) const length = vec.length() if (length < 0.5) continue const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR) const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color, Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055))) for (const m of [arrow.line.material, arrow.cone.material]) { m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false } arrow.renderOrder = 18 arrow.visible = false this.killLineGroup.add(arrow) this.killLines.push({ time: k.time, arrow }) } } _updateKillLines() { for (const { time, arrow } of this.killLines) { const age = this.currentT - time const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS arrow.visible = visible if (!visible) continue const opacity = 1 - age / KILL_LINE_WINDOW_MS arrow.line.material.opacity = 0.18 + opacity * 0.62 arrow.cone.material.opacity = 0.24 + opacity * 0.72 } } // ---- interpolation ------------------------------------------------------- _findSegment(entity, t) { const path = entity.path if (t < entity.startT || t > entity.endT) return null let lo = 0, hi = path.length - 1 while (lo < hi - 1) { const mid = (lo + hi) >> 1 if (path[mid].t <= t) lo = mid; else hi = mid } return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] } } _interp(entity, t) { const seg = this._findSegment(entity, t) if (!seg) return null const { index, a, b } = seg const path = entity.path const span = Math.max(1, b.t - a.t) const alpha = Math.min(1, Math.max(0, (t - a.t) / span)) const before = path[Math.max(0, index - 1)] const after = path[Math.min(path.length - 1, index + 2)] return { t, x: catmullRom(before.x, a.x, b.x, after.x, alpha), y: catmullRom(before.y, a.y, b.y, after.y, alpha), z: catmullRom(before.z, a.z, b.z, after.z, alpha), } } _directionAt(entity, t) { const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS) const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS) if (afterT <= beforeT) return null const before = this._interp(entity, beforeT) const after = this._interp(entity, afterT) if (!before || !after) return null const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before))) if (dir.lengthSq() < 0.0001) return null return dir.normalize() } _blendedDirection(visual, t) { const raw = this._directionAt(visual.entity, t) if (!raw) return visual.smoothedDirection const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800 if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone() else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize() visual.lastDirectionT = t return visual.smoothedDirection } // ---- highlight / focus --------------------------------------------------- _applyHighlight() { const sel = this.selectedPlayerId if (!this.visualEntities) return for (const visual of this.visualEntities) { const isSel = sel != null && visual.entity.playerId === sel const teamColor = this._colorFor(visual.entity) const mat = visual.line.material if (sel == null) { mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0 } else if (isSel) { mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30 } else { mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0 } if (visual.model?.isGroup) { visual.model.traverse((child) => { if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1 }) } } } focus(playerId) { this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId this._applyHighlight() if (this.selectedPlayerId == null) return const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible) || (this.visualEntities || []).find((v) => v.entity.playerId === playerId) if (!visual) return const p = this._interp(visual.entity, this.currentT) if (!p) return const world = this._toWorld(this._renderPoint(p)) const offset = this.camera.position.clone().sub(this.controls.target) this.controls.target.copy(world) this.camera.position.copy(world.clone().add(offset)) this.controls.update() } // ---- public API ---------------------------------------------------------- setTime(t) { this.currentT = t if (!this.visualEntities) return for (const visual of this.visualEntities) { const p = this._interp(visual.entity, t) visual.group.visible = Boolean(p) if (!p) continue visual.group.position.copy(this._toWorld(this._renderPoint(p))) const dir = this._blendedDirection(visual, t) visual.arrow.visible = Boolean(dir) if (dir) { visual.arrow.setDirection(dir) if (visual.model?.isGroup) { const flat = dir.clone(); flat.y = 0 if (flat.lengthSq() > 0) { flat.normalize() visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat) visual.model.rotateY(Math.PI) visual.model.rotateY(-Math.PI / 2) } } } } this._updateCaptureZones() this._updateKillLines() } setMode(mode) { const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground' if (next === this._mode) return this._mode = next this.mapInfo = this._resolveMapInfo(next) this.smoothByEntity.clear() this._buildScene() this._loadMapTexture() this.setTime(this.currentT) } resize() { const w = this.container.clientWidth || this.container.offsetWidth || 720 const h = this.container.clientHeight || w this.renderer.setSize(w, h, false) this.camera.aspect = w / Math.max(1, h) this.camera.updateProjectionMatrix() } _animate() { if (this.disposed) return this.controls.update() this.renderer.render(this.scene, this.camera) this._raf = requestAnimationFrame(this._animate) } dispose() { this.disposed = true if (this._raf) cancelAnimationFrame(this._raf) this.controls?.dispose() this._clearGroup(this.mapGroup) this._clearGroup(this.pathGroup) this._clearGroup(this.markerGroup) this._clearGroup(this.zoneGroup) this._clearGroup(this.killLineGroup) this.renderer?.dispose() if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl) } } window.ReplayCanvas3D = ReplayCanvas3D