add 3d to srebot (#1350)

This commit is contained in:
NotSoToothless
2026-06-21 07:54:35 -07:00
committed by GitHub
parent 28a635438d
commit 8a084fb644
9 changed files with 37226 additions and 6 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ if (!fs.existsSync(OUTPUT_DIR)) {
console.log('[BUILD] Starting JavaScript obfuscation...');
// Get all JS files in the public/js directory
const SKIP_FILES = ['replay-canvas.js'];
const SKIP_FILES = ['replay-canvas.js', 'replay-canvas-3d.js'];
const jsFiles = fs.readdirSync(PUBLIC_JS_DIR).filter(file =>
file.endsWith('.js') && !file.startsWith('.') && !SKIP_FILES.includes(file)
);
+696
View File
@@ -0,0 +1,696 @@
// Pure 3D replay view. No DOM beyond its own <canvas>; 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 CAP_INTERP_MAX_GAP_MS = 4000
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._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)
}
}
_captureValueAt(zone, t) {
if (!zone.cap.length) return 0
if (t <= zone.cap[0][0]) return zone.cap[0][1]
for (let i = 1; i < zone.cap.length; i++) {
const prev = zone.cap[i - 1]
const cur = zone.cap[i]
if (t <= cur[0]) {
const gap = cur[0] - prev[0]
if (gap > CAP_INTERP_MAX_GAP_MS) return prev[1]
const alpha = (t - prev[0]) / Math.max(1, gap)
return THREE.MathUtils.lerp(prev[1], cur[1], THREE.MathUtils.clamp(alpha, 0, 1))
}
}
return zone.cap[zone.cap.length - 1][1]
}
_updateCaptureZones() {
for (const group of this.zoneGroup.children) {
const { zone, fill } = group.userData
if (!zone || !fill) continue
const value = THREE.MathUtils.clamp(this._captureValueAt(zone, this.currentT), -100, 100)
const amount = Math.abs(value) / 100
fill.visible = value !== 0
fill.material.color.set(value > 0 ? LOSE_COLOR : value < 0 ? WIN_COLOR : 0xd8dee9)
fill.material.opacity = value === 0 ? 0 : 0.18 + amount * 0.34
const s = value === 0 ? 0 : Math.sqrt(amount)
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
+57 -4
View File
@@ -53,6 +53,9 @@ class ReplayCanvas {
this._teamNames = (data.teamNames && typeof data.teamNames === 'object') ? data.teamNames : {};
this._winnerSlot = Number(data.winnerSlot) || 0; // winning team slot (1/2)
this._mode = 'ground';
this._viewMode = '2d';
this.view3d = null;
this.supports3d = false;
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
@@ -166,6 +169,9 @@ class ReplayCanvas {
async init() {
this._buildDOM();
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
this._initView3d();
this._onResize = () => { if (this._viewMode === '3d') this.view3d?.resize(); };
window.addEventListener('resize', this._onResize);
this.playing = true;
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
this.lastFrameTime = performance.now();
@@ -173,6 +179,42 @@ class ReplayCanvas {
this.animFrameId = requestAnimationFrame(this._tick);
}
_initView3d() {
try {
if (typeof window.ReplayCanvas3D !== 'function') return;
this.view3d = new window.ReplayCanvas3D(this.view3dContainer, this.data);
this.supports3d = true;
} catch (e) {
this.view3d = null;
this.supports3d = false;
}
}
_renderActive() {
if (this._viewMode === '3d') { this.view3d?.setTime(this.currentTime); }
else { this.render(); }
}
setViewMode(mode) {
const next = mode === '3d' && this.view3d ? '3d' : '2d';
if (next === this._viewMode) return;
this._viewMode = next;
const is3d = next === '3d';
this.canvas.classList.toggle('rc-hidden', is3d);
this.view3dContainer.classList.toggle('rc-hidden', !is3d);
if (is3d) {
this.view3d.setMode(this._mode);
this.view3d.resize();
this.view3d.setTime(this.currentTime);
} else {
this.render();
}
}
focus(playerId) {
if (this._viewMode === '3d') this.view3d?.focus(playerId);
}
_buildDOM() {
this.container.innerHTML = '';
const layout = document.createElement('div');
@@ -190,12 +232,18 @@ class ReplayCanvas {
// Tickets meter above the battle view (its top aligns with the team panels)
this._buildTicketsBar(center);
const stage = document.createElement('div');
stage.className = 'rc-stage';
this.canvas = document.createElement('canvas');
this.canvas.width = this.canvasSize;
this.canvas.height = this.canvasSize;
this.canvas.className = 'rc-canvas';
this.ctx = this.canvas.getContext('2d');
center.appendChild(this.canvas);
stage.appendChild(this.canvas);
this.view3dContainer = document.createElement('div');
this.view3dContainer.className = 'rc-3d-container rc-hidden';
stage.appendChild(this.view3dContainer);
center.appendChild(stage);
// Controls
const controls = document.createElement('div');
@@ -243,7 +291,7 @@ class ReplayCanvas {
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
this._updatePanelDeathStates();
this._updateBattleLog();
this.render();
this._renderActive();
this._updateTicketsBar(this.currentTime);
});
controls.querySelectorAll('.rc-sp').forEach(btn => {
@@ -331,6 +379,7 @@ class ReplayCanvas {
}
});
row.addEventListener('mouseleave', () => this._setHighlight(null));
row.addEventListener('click', () => this.focus(parseInt(row.dataset.playerId)));
});
}
@@ -873,8 +922,9 @@ class ReplayCanvas {
this._drawMapToCanvas();
// Recompute death positions in new coordinate space
this._computeDeaths();
this.view3d?.setMode(mode);
// Render immediately
this.render();
this._renderActive();
}
_togglePlay() {
@@ -897,7 +947,7 @@ class ReplayCanvas {
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
}
}
this.render();
this._renderActive();
this._updateTicketsBar(this.currentTime);
this._updateControls();
// Update panel death states + battle log every ~250ms
@@ -1145,6 +1195,9 @@ class ReplayCanvas {
destroy() {
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
if (this._onResize) window.removeEventListener('resize', this._onResize);
this.view3d?.dispose();
this.view3d = null;
this.container.innerHTML = '';
}
}
+12
View File
@@ -0,0 +1,12 @@
# 3ds Max Wavefront OBJ Exporter v0.97b - (c)2007 guruware
# File Created: 27.04.2017 18:23:39
newmtl wire_022022022
Ns 32
d 1
Tr 0
Tf 1 1 1
illum 2
Ka 0.0863 0.0863 0.0863
Kd 0.0863 0.0863 0.0863
Ks 0.3500 0.3500 0.3500
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+955
View File
@@ -0,0 +1,955 @@
import {
BufferGeometry,
FileLoader,
Float32BufferAttribute,
Group,
LineBasicMaterial,
LineSegments,
Loader,
Material,
Mesh,
MeshPhongMaterial,
Points,
PointsMaterial,
Vector3,
Color,
SRGBColorSpace
} from 'three';
// o object_name | g group_name
const _object_pattern = /^[og]\s*(.+)?/;
// mtllib file_reference
const _material_library_pattern = /^mtllib /;
// usemtl material_name
const _material_use_pattern = /^usemtl /;
// usemap map_name
const _map_use_pattern = /^usemap /;
const _face_vertex_data_separator_pattern = /\s+/;
const _vA = new Vector3();
const _vB = new Vector3();
const _vC = new Vector3();
const _ab = new Vector3();
const _cb = new Vector3();
const _color = new Color();
function ParserState() {
const state = {
objects: [],
object: {},
vertices: [],
normals: [],
colors: [],
uvs: [],
materials: {},
materialLibraries: [],
startObject: function ( name, fromDeclaration ) {
// If the current object (initial from reset) is not from a g/o declaration in the parsed
// file. We need to use it for the first parsed g/o to keep things in sync.
if ( this.object && this.object.fromDeclaration === false ) {
this.object.name = name;
this.object.fromDeclaration = ( fromDeclaration !== false );
return;
}
const previousMaterial = ( this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined );
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
this.object = {
name: name || '',
fromDeclaration: ( fromDeclaration !== false ),
geometry: {
vertices: [],
normals: [],
colors: [],
uvs: [],
hasUVIndices: false
},
materials: [],
smooth: true,
startMaterial: function ( name, libraries ) {
const previous = this._finalize( false );
// New usemtl declaration overwrites an inherited material, except if faces were declared
// after the material, then it must be preserved for proper MultiMaterial continuation.
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
this.materials.splice( previous.index, 1 );
}
const material = {
index: this.materials.length,
name: name || '',
mtllib: ( Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '' ),
smooth: ( previous !== undefined ? previous.smooth : this.smooth ),
groupStart: ( previous !== undefined ? previous.groupEnd : 0 ),
groupEnd: - 1,
groupCount: - 1,
inherited: false,
clone: function ( index ) {
const cloned = {
index: ( typeof index === 'number' ? index : this.index ),
name: this.name,
mtllib: this.mtllib,
smooth: this.smooth,
groupStart: 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false
};
cloned.clone = this.clone.bind( cloned );
return cloned;
}
};
this.materials.push( material );
return material;
},
currentMaterial: function () {
if ( this.materials.length > 0 ) {
return this.materials[ this.materials.length - 1 ];
}
return undefined;
},
_finalize: function ( end ) {
const lastMultiMaterial = this.currentMaterial();
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
lastMultiMaterial.inherited = false;
}
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
if ( end && this.materials.length > 1 ) {
for ( let mi = this.materials.length - 1; mi >= 0; mi -- ) {
if ( this.materials[ mi ].groupCount <= 0 ) {
this.materials.splice( mi, 1 );
}
}
}
// Guarantee at least one empty material, this makes the creation later more straight forward.
if ( end && this.materials.length === 0 ) {
this.materials.push( {
name: '',
smooth: this.smooth
} );
}
return lastMultiMaterial;
}
};
// Inherit previous objects material.
// Spec tells us that a declared material must be set to all objects until a new material is declared.
// If a usemtl declaration is encountered while this new object is being parsed, it will
// overwrite the inherited material. Exception being that there was already face declarations
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
const declared = previousMaterial.clone( 0 );
declared.inherited = true;
this.object.materials.push( declared );
}
this.objects.push( this.object );
},
finalize: function () {
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
},
parseVertexIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseNormalIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseUVIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
},
addVertex: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addVertexPoint: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addVertexLine: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addNormal: function ( a, b, c ) {
const src = this.normals;
const dst = this.object.geometry.normals;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addFaceNormal: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.normals;
_vA.fromArray( src, a );
_vB.fromArray( src, b );
_vC.fromArray( src, c );
_cb.subVectors( _vC, _vB );
_ab.subVectors( _vA, _vB );
_cb.cross( _ab );
_cb.normalize();
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
},
addColor: function ( a, b, c ) {
const src = this.colors;
const dst = this.object.geometry.colors;
if ( src[ a ] !== undefined ) dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
if ( src[ b ] !== undefined ) dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
if ( src[ c ] !== undefined ) dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addUV: function ( a, b, c ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
dst.push( src[ b + 0 ], src[ b + 1 ] );
dst.push( src[ c + 0 ], src[ c + 1 ] );
},
addDefaultUV: function () {
const dst = this.object.geometry.uvs;
dst.push( 0, 0 );
dst.push( 0, 0 );
dst.push( 0, 0 );
},
addUVLine: function ( a ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
},
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
const vLen = this.vertices.length;
let ia = this.parseVertexIndex( a, vLen );
let ib = this.parseVertexIndex( b, vLen );
let ic = this.parseVertexIndex( c, vLen );
this.addVertex( ia, ib, ic );
this.addColor( ia, ib, ic );
// normals
if ( na !== undefined && na !== '' ) {
const nLen = this.normals.length;
ia = this.parseNormalIndex( na, nLen );
ib = this.parseNormalIndex( nb, nLen );
ic = this.parseNormalIndex( nc, nLen );
this.addNormal( ia, ib, ic );
} else {
this.addFaceNormal( ia, ib, ic );
}
// uvs
if ( ua !== undefined && ua !== '' ) {
const uvLen = this.uvs.length;
ia = this.parseUVIndex( ua, uvLen );
ib = this.parseUVIndex( ub, uvLen );
ic = this.parseUVIndex( uc, uvLen );
this.addUV( ia, ib, ic );
this.object.geometry.hasUVIndices = true;
} else {
// add placeholder values (for inconsistent face definitions)
this.addDefaultUV();
}
},
addPointGeometry: function ( vertices ) {
this.object.geometry.type = 'Points';
const vLen = this.vertices.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
const index = this.parseVertexIndex( vertices[ vi ], vLen );
this.addVertexPoint( index );
this.addColor( index );
}
},
addLineGeometry: function ( vertices, uvs ) {
this.object.geometry.type = 'Line';
const vLen = this.vertices.length;
const uvLen = this.uvs.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
for ( let uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
}
}
};
state.startObject( '', false );
return state;
}
/**
* A loader for the OBJ format.
*
* The [OBJ format](https://en.wikipedia.org/wiki/Wavefront_.obj_file) is a simple data-format that
* represents 3D geometry in a human readable format as the position of each vertex, the UV position of
* each texture coordinate vertex, vertex normals, and the faces that make each polygon defined as a list
* of vertices, and texture vertices.
*
* ```js
* const loader = new OBJLoader();
* const object = await loader.loadAsync( 'models/monster.obj' );
* scene.add( object );
* ```
*
* @augments Loader
* @three_import import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
*/
class OBJLoader extends Loader {
/**
* Constructs a new OBJ loader.
*
* @param {LoadingManager} [manager] - The loading manager.
*/
constructor( manager ) {
super( manager );
/**
* A reference to a material creator.
*
* @type {?MaterialCreator}
* @default null
*/
this.materials = null;
}
/**
* Starts loading from the given URL and passes the loaded OBJ asset
* to the `onLoad()` callback.
*
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
* @param {function(Group)} onLoad - Executed when the loading process has been finished.
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
/**
* Sets the material creator for this OBJ. This object is loaded via {@link MTLLoader}.
*
* @param {MaterialCreator} materials - An object that creates the materials for this OBJ.
* @return {OBJLoader} A reference to this loader.
*/
setMaterials( materials ) {
this.materials = materials;
return this;
}
/**
* Parses the given OBJ data and returns the resulting group.
*
* @param {string} text - The raw OBJ data as a string.
* @return {Group} The parsed OBJ.
*/
parse( text ) {
const state = new ParserState();
if ( text.indexOf( '\r\n' ) !== - 1 ) {
// This is faster than String.split with regex that splits on both
text = text.replace( /\r\n/g, '\n' );
}
if ( text.indexOf( '\\\n' ) !== - 1 ) {
// join lines separated by a line continuation character (\)
text = text.replace( /\\\n/g, '' );
}
const lines = text.split( '\n' );
let result = [];
for ( let i = 0, l = lines.length; i < l; i ++ ) {
const line = lines[ i ].trimStart();
if ( line.length === 0 ) continue;
const lineFirstChar = line.charAt( 0 );
// @todo invoke passed in handler if any
if ( lineFirstChar === '#' ) continue; // skip comments
if ( lineFirstChar === 'v' ) {
const data = line.split( _face_vertex_data_separator_pattern );
switch ( data[ 0 ] ) {
case 'v':
state.vertices.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
if ( data.length >= 7 ) {
_color.setRGB(
parseFloat( data[ 4 ] ),
parseFloat( data[ 5 ] ),
parseFloat( data[ 6 ] ),
SRGBColorSpace
);
state.colors.push( _color.r, _color.g, _color.b );
} else {
// if no colors are defined, add placeholders so color and vertex indices match
state.colors.push( undefined, undefined, undefined );
}
break;
case 'vn':
state.normals.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
break;
case 'vt':
state.uvs.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] )
);
break;
}
} else if ( lineFirstChar === 'f' ) {
const lineData = line.slice( 1 ).trim();
const vertexData = lineData.split( _face_vertex_data_separator_pattern );
const faceVertices = [];
// Parse the face vertex data into an easy to work with format
for ( let j = 0, jl = vertexData.length; j < jl; j ++ ) {
const vertex = vertexData[ j ];
if ( vertex.length > 0 ) {
const vertexParts = vertex.split( '/' );
faceVertices.push( vertexParts );
}
}
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
const v1 = faceVertices[ 0 ];
for ( let j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
const v2 = faceVertices[ j ];
const v3 = faceVertices[ j + 1 ];
state.addFace(
v1[ 0 ], v2[ 0 ], v3[ 0 ],
v1[ 1 ], v2[ 1 ], v3[ 1 ],
v1[ 2 ], v2[ 2 ], v3[ 2 ]
);
}
} else if ( lineFirstChar === 'l' ) {
const lineParts = line.substring( 1 ).trim().split( ' ' );
let lineVertices = [];
const lineUVs = [];
if ( line.indexOf( '/' ) === - 1 ) {
lineVertices = lineParts;
} else {
for ( let li = 0, llen = lineParts.length; li < llen; li ++ ) {
const parts = lineParts[ li ].split( '/' );
if ( parts[ 0 ] !== '' ) lineVertices.push( parts[ 0 ] );
if ( parts[ 1 ] !== '' ) lineUVs.push( parts[ 1 ] );
}
}
state.addLineGeometry( lineVertices, lineUVs );
} else if ( lineFirstChar === 'p' ) {
const lineData = line.slice( 1 ).trim();
const pointData = lineData.split( ' ' );
state.addPointGeometry( pointData );
} else if ( ( result = _object_pattern.exec( line ) ) !== null ) {
// o object_name
// or
// g group_name
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
// let name = result[ 0 ].slice( 1 ).trim();
const name = ( ' ' + result[ 0 ].slice( 1 ).trim() ).slice( 1 );
state.startObject( name );
} else if ( _material_use_pattern.test( line ) ) {
// material
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
} else if ( _material_library_pattern.test( line ) ) {
// mtl file
state.materialLibraries.push( line.substring( 7 ).trim() );
} else if ( _map_use_pattern.test( line ) ) {
// the line is parsed but ignored since the loader assumes textures are defined MTL files
// (according to https://www.okino.com/conv/imp_wave.htm, 'usemap' is the old-style Wavefront texture reference method)
console.warn( 'THREE.OBJLoader: Rendering identifier "usemap" not supported. Textures must be defined in MTL files.' );
} else if ( lineFirstChar === 's' ) {
result = line.split( ' ' );
// smooth shading
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
// but does not define a usemtl for each face set.
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
// This requires some care to not create extra material on each smooth value for "normal" obj files.
// where explicit usemtl defines geometry groups.
// Example asset: examples/models/obj/cerberus/Cerberus.obj
/*
* http://paulbourke.net/dataformats/obj/
*
* From chapter "Grouping" Syntax explanation "s group_number":
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
* than 0."
*/
if ( result.length > 1 ) {
const value = result[ 1 ].trim().toLowerCase();
state.object.smooth = ( value !== '0' && value !== 'off' );
} else {
// ZBrush can produce "s" lines #11707
state.object.smooth = true;
}
const material = state.object.currentMaterial();
if ( material ) material.smooth = state.object.smooth;
} else {
// Handle null terminated files without exception
if ( line === '\0' ) continue;
console.warn( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
}
}
state.finalize();
const container = new Group();
container.materialLibraries = [].concat( state.materialLibraries );
const hasPrimitives = ! ( state.objects.length === 1 && state.objects[ 0 ].geometry.vertices.length === 0 );
if ( hasPrimitives === true ) {
for ( let i = 0, l = state.objects.length; i < l; i ++ ) {
const object = state.objects[ i ];
const geometry = object.geometry;
const materials = object.materials;
const isLine = ( geometry.type === 'Line' );
const isPoints = ( geometry.type === 'Points' );
let hasVertexColors = false;
// Skip o/g line declarations that did not follow with any faces
if ( geometry.vertices.length === 0 ) continue;
const buffergeometry = new BufferGeometry();
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( geometry.vertices, 3 ) );
if ( geometry.normals.length > 0 ) {
buffergeometry.setAttribute( 'normal', new Float32BufferAttribute( geometry.normals, 3 ) );
}
if ( geometry.colors.length > 0 ) {
hasVertexColors = true;
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( geometry.colors, 3 ) );
}
if ( geometry.hasUVIndices === true ) {
buffergeometry.setAttribute( 'uv', new Float32BufferAttribute( geometry.uvs, 2 ) );
}
// Create materials
const createdMaterials = [];
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
const materialHash = sourceMaterial.name + '_' + sourceMaterial.smooth + '_' + hasVertexColors;
let material = state.materials[ materialHash ];
if ( this.materials !== null ) {
material = this.materials.create( sourceMaterial.name );
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
if ( isLine && material && ! ( material instanceof LineBasicMaterial ) ) {
const materialLine = new LineBasicMaterial();
Material.prototype.copy.call( materialLine, material );
materialLine.color.copy( material.color );
material = materialLine;
} else if ( isPoints && material && ! ( material instanceof PointsMaterial ) ) {
const materialPoints = new PointsMaterial( { size: 10, sizeAttenuation: false } );
Material.prototype.copy.call( materialPoints, material );
materialPoints.color.copy( material.color );
materialPoints.map = material.map;
material = materialPoints;
}
}
if ( material === undefined ) {
if ( isLine ) {
material = new LineBasicMaterial();
} else if ( isPoints ) {
material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
} else {
material = new MeshPhongMaterial();
}
material.name = sourceMaterial.name;
material.flatShading = sourceMaterial.smooth ? false : true;
material.vertexColors = hasVertexColors;
state.materials[ materialHash ] = material;
}
createdMaterials.push( material );
}
// Create mesh
let mesh;
if ( createdMaterials.length > 1 ) {
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
}
if ( isLine ) {
mesh = new LineSegments( buffergeometry, createdMaterials );
} else if ( isPoints ) {
mesh = new Points( buffergeometry, createdMaterials );
} else {
mesh = new Mesh( buffergeometry, createdMaterials );
}
} else {
if ( isLine ) {
mesh = new LineSegments( buffergeometry, createdMaterials[ 0 ] );
} else if ( isPoints ) {
mesh = new Points( buffergeometry, createdMaterials[ 0 ] );
} else {
mesh = new Mesh( buffergeometry, createdMaterials[ 0 ] );
}
}
mesh.name = object.name;
container.add( mesh );
}
} else {
// if there is only the default parser state object with no geometry data, interpret data as point cloud
if ( state.vertices.length > 0 ) {
const material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
const buffergeometry = new BufferGeometry();
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( state.vertices, 3 ) );
if ( state.colors.length > 0 && state.colors[ 0 ] !== undefined ) {
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( state.colors, 3 ) );
material.vertexColors = true;
}
const points = new Points( buffergeometry, material );
container.add( points );
}
}
return container;
}
}
export { OBJLoader };
File diff suppressed because one or more lines are too long
+61 -1
View File
@@ -488,6 +488,18 @@
align-items: center;
min-width: 0;
}
.rc-stage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
}
.rc-stage .rc-canvas,
.rc-stage .rc-3d-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rc-canvas {
width: 100%;
height: auto;
@@ -496,6 +508,23 @@
cursor: crosshair;
border: 1px solid rgba(255,255,255,0.06);
}
.rc-3d-container {
border-radius: 0.4rem;
overflow: hidden;
background: #0b0d10;
border: 1px solid rgba(255,255,255,0.06);
}
.rc-3d-container canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.rc-hidden { display: none !important; }
.rc-mode-btn-disabled {
opacity: 0.35;
cursor: not-allowed;
}
.rc-tickets {
display: flex;
align-items: center;
@@ -695,6 +724,10 @@
<button class="rc-mode-btn active" data-mode="ground" onclick="switchReplayMode('ground')"><i class="fas fa-crosshairs" style="margin-right: 4px;"></i><%= t('games.modeGround') %></button>
<button class="rc-mode-btn" data-mode="air" onclick="switchReplayMode('air')"><i class="fas fa-plane" style="margin-right: 4px;"></i><%= t('games.modeAir') %></button>
</div>
<div class="rc-mode-toggle" id="rcViewToggle" onclick="event.stopPropagation()">
<button class="rc-mode-btn active" data-view="2d" onclick="switchReplayView('2d')">2D</button>
<button class="rc-mode-btn" data-view="3d" onclick="switchReplayView('3d')">3D</button>
</div>
<i class="fas fa-chevron-down" id="replayViewerToggleIcon"></i>
</div>
<div class="log-body" id="replayViewerBody" style="max-height: none; padding: 1rem; font-family: inherit;">
@@ -749,6 +782,15 @@
<script src="/js/api-client.js?v=3"></script>
<script src="/js/vehicle-i18n.js"></script>
<script src="/js/header-search.js?v=2"></script>
<script type="importmap">
{
"imports": {
"three": "/vendor/three/three.module.js",
"three/addons/": "/vendor/three/addons/"
}
}
</script>
<script type="module" src="/js/replay-canvas-3d.js?v=1"></script>
<script src="/js/replay-canvas.js?v=15"></script>
<script>
const sessionId = '<%= sessionId %>';
@@ -939,6 +981,15 @@
if (replayViewer.hasAirMode) {
document.getElementById('rcModeToggle').classList.add('visible');
}
// Always show the 2D/3D toggle; dim 3D if unsupported.
const viewToggle = document.getElementById('rcViewToggle');
viewToggle.classList.add('visible');
if (!replayViewer.supports3d) {
const btn3d = viewToggle.querySelector('[data-view="3d"]');
btn3d.disabled = true;
btn3d.classList.add('rc-mode-btn-disabled');
btn3d.title = '3D view not supported on this device';
}
} catch (err) {
loading.style.display = 'block';
loading.innerHTML = '<div class="video-fallback" style="color:#ff6666;">' + (err.message || 'Unknown error') + '</div>';
@@ -948,11 +999,20 @@
function switchReplayMode(mode) {
if (!replayViewer) return;
replayViewer.setMode(mode);
document.querySelectorAll('.rc-mode-btn').forEach(btn => {
document.querySelectorAll('#rcModeToggle .rc-mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
}
function switchReplayView(view) {
if (!replayViewer) return;
replayViewer.setViewMode(view);
const active = replayViewer._viewMode;
document.querySelectorAll('#rcViewToggle .rc-mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === active);
});
}
function toggleLog(logId) {
const body = document.getElementById(logId + 'Body');
const icon = document.getElementById(logId + 'Icon');