; ; /* ============================================================================ AD BOARDS — in-world video-texture billboards ---------------------------------------------------------------------------- Ads are DIEGETIC PROPS here, not a UI overlay: a small number of billboards and jumbotron screens stand in the battlefield itself, beside the highway and around the derelict city districts, playing looping video like anything else the war left running. There is no ad network wired up yet — only bundled placeholder clips — but every path a real network would need (request a creative for a slot, report an impression, report a failure) already exists behind the AdProvider interface below. Turning on AdMob or a VAST tag later is meant to be AD_CONFIG.provider flipping from 'local' to 'network', not a rewrite of the renderer. This file plugs into an already-running engine without editing any of it. Three existing global FUNCTIONS are wrapped (not replaced) at load time: setupDoodads — so board placement is (re)computed right after the terrain, road grid and city districts exist for whatever map is current, exactly like rocks/trees/crystals are. begin3D — the model-shader "frame prologue" render.js calls before drawing any lit geometry. Hooking it is what lets the board frames draw through the SAME lit/fogged/SSAO'd pipeline every other structure uses, and gives a once- per-frame timing point for the throttled video-texture upload, all without a second render pass. renderSettings — appends one more row to #setList after the real function has built the list, so the toggle lives in the normal settings screen without touching meta.js. Every wrapper calls the ORIGINAL function first, wraps its own work in try/catch, and never lets a failure in here reach the caller — four other systems are mid-development in this same global scope and a bug in an ad board must never be able to take the render loop or the settings screen down with it. CRITICAL GL HYGIENE: the post-processing chain (SSAO/bloom/FXAA, in engine/mesh.js) owns texture units 4/5/6 and the model/terrain shaders own 0/1/2/3. Unit 7 is ALSO the model detail atlas and unit 8 the fog map — ads borrow them for the screen draw, then put detail/fog/matTex back. Never bindTexture(null) on the active unit (that was unit 0 / the atlas). Restore BLEND / CULL_FACE / DEPTH_TEST / DEPTH_WRITEMASK / prog3D. ============================================================================ */ /* ============================================================================ BILLBOARD PROP — geometry ---------------------------------------------------------------------------- Authored the same way every other structure in engine/models.js is: welded from MeshBuilder primitives, feet at y=0, facing local +X, real world-scale units (an instance scale of ~1 is a real billboard). The screen itself is NOT part of this mesh — it can't be, its content changes every frame — this only builds the frame it sits in: footings, posts, a catwalk, hazard trim, a lighting boom and a backing panel sized to exactly match the video quad adScreenVerts() computes below. The two are kept in registration by sharing the same AD_* layout constants. ============================================================================ */ const AD_HALFW = 14.2; // screen half-width, local Z const AD_SCR_H = 16.0; // screen height const AD_BOT_Y = 13.0; // screen bottom, world units above ground const AD_FACE_X = 1.02; // local X of the screen plane — just proud of the backing panel function mdlAdBoard(){ const m = MB(); const hw = AD_HALFW, topY = AD_BOT_Y + AD_SCR_H; // footings + support posts, one each side of the screen for (const s of [-1, 1]) { const pz = s * hw * 0.70; m.cyl(0.0, 0.0, pz, 2.5, 2.4, 1.0, 10, CONC); // concrete footing m.cyl(0.0, 1.0, pz, 1.05, 0.90, AD_BOT_Y - 1.0, 10, MET_D); // support post m.cyl(0.0, AD_BOT_Y - 1.0, pz, 0.90, 0.80, 1.3, 10, MET_L); // collar where the catwalk lands } // horizontal cross braces tying the posts together m.box(0.0, 4.5, 0, 1.0, 0.9, hw * 1.42, DARK); m.box(0.0, 8.5, 0, 1.0, 0.9, hw * 1.42, DARK); // maintenance catwalk + a glowing guard-rail along its front edge m.bevelBox(0.20, AD_BOT_Y - 1.40, 0, 2.0, 0.5, hw * 1.70, 0.2, MET); glowStrip(m, 1.15, AD_BOT_Y - 0.50, 0, hw * 1.65, MET_L, Math.PI / 2); // hazard stripe band along the base — no existing palette constant maps to // MAT.WARN, so the material is set explicitly for this one primitive; every // primitive after it uses a recognised palette colour again and resets it m.mat(MAT.WARN); m.box(0.05, AD_BOT_Y - 2.30, 0, 0.9, 0.75, hw * 1.60, C(255, 255, 255)); // backing panel — the screen quad sits flush against its front face m.bevelBox(0.40, AD_BOT_Y - 1.50, 0, 1.2, AD_SCR_H + 4.0, hw * 2 + 3.0, 0.4, DARKER); // lighting boom + spotlights angled down at the screen m.box(0.60, topY + 0.60, 0, 2.6, 0.5, hw * 1.70, MET_D); for (const t of [-0.75, -0.25, 0.25, 0.75]) { const lz = t * hw * 1.55; m.box(1.60, topY + 0.20, lz, 1.6, 0.6, 0.6, DARK); m.box(2.30, topY + 0.10, lz, 0.6, 0.5, 0.9, LAMP, -0.35); } // emissive trim along the top and bottom edges — reads at night even before // the screen itself is considered "glowing" glowStrip(m, AD_FACE_X, topY + 0.35, 0, hw * 2 + 0.6, ENERGY, Math.PI / 2); glowStrip(m, AD_FACE_X, AD_BOT_Y - 0.35, 0, hw * 2 + 0.6, ENERGY, Math.PI / 2); // antenna + a crate of gear at the base for clutter, same vocabulary every // other structure in the game is built from sensorMast(m, 0.40, topY + 1.10, hw * 0.95, 3.4, MET_L); kitBox(m, 0.30, 0.10, hw * 0.70 + 1.6, 1.8, 1.3, 1.6, MET_D, 0.25); return m.build(); } /* ============================================================================ PLACEMENT — deterministic per map, seeded like every other doodad ---------------------------------------------------------------------------- Runs from the setupDoodads() wrapper (see adInstallHooks), so ROADG, cityZones/cityPlan and the height field are already built for whatever map is current. Uses the SAME srand()/rnd()/rr() generator gl.js's own doodad placement uses, but reseeds it itself first — so this never depends on (or disturbs) whatever state that shared generator was left in — and never places on top of a spawn's safety ring, a resource deposit, water, or another board. ============================================================================ */ const AD_MAX = 10; let adBoards = []; function AdSlot(id, x, y, yaw, scale) { return { id, x, y, yaw, scale, placement: 'billboard', size: { w: AD_HALFW * 2 * scale, h: AD_SCR_H * scale }, creative: null, // filled in asynchronously by adAssignCreatives() _dwell: 0, _counted: false, _onscreen: false, // crossfade rotation state — second creative slot + blend progress creative2: null, _rotT: 0, _blend: 0, }; } /* Beside the highway: walk the rasterised road grid looking for cells that are ON the road, then probe outward from each for the nearest clear shoulder — clear of the road itself, walkable, not water, not a cliff. */ function adScanRoadSpots(validSpot, tooClose) { const spots = []; if (typeof ROADG === 'undefined' || !ROADG) return spots; const cellW = MAP / PGS; const onRoad = (x, y) => !!ROADG[clamp(y / MAP * PGS | 0, 0, PGS - 1) * PGS + clamp(x / MAP * PGS | 0, 0, PGS - 1)]; /* tooClose() alone only ever sees adBoards, and adBoards is still EMPTY for the whole duration of a scan — adPlaceBoards() doesn't push a scan's results into it until the scan has already returned in full. Two candidates accepted earlier in this SAME scan therefore never got checked against each other, so a run of unlucky road cells could place two boards on top of one another. tooCloseAny() closes that gap by also checking what this scan has already accepted. */ const tooCloseAny = (x, y, d) => tooClose(x, y, d) || spots.some(s => dist2(x, y, s.x, s.y) < d * d); for (let gy = 3; gy < PGS - 3 && spots.length < 4; gy += 5) { for (let gx = 3; gx < PGS - 3 && spots.length < 4; gx += 5) { if (!ROADG[gy * PGS + gx]) continue; const rx = (gx + 0.5) * cellW, ry = (gy + 0.5) * cellW; if (tooCloseAny(rx, ry, 300)) continue; for (let t = 0; t < 10; t++) { const ang = rnd() * TAU, dist = 74 + rnd() * 56; const px = rx + Math.cos(ang) * dist, py = ry + Math.sin(ang) * dist; if (onRoad(px, py)) continue; if (!validSpot(px, py) || tooCloseAny(px, py, 300)) continue; spots.push({ x: px, y: py, yaw: ang + Math.PI }); // face back toward the highway break; } } } return spots; } /* In the city: one board per derelict/industrial district, planted just outside the block footprints on the district's rim, facing in. */ function adScanCitySpots(validSpot, tooClose) { const spots = []; if (typeof cityZones === 'undefined') return spots; const tooCloseAny = (x, y, d) => tooClose(x, y, d) || spots.some(s => dist2(x, y, s.x, s.y) < d * d); // see adScanRoadSpots cityZones.forEach((Z, zi) => { for (let t = 0; t < 14; t++) { const ang = rnd() * TAU, rad = Z.r * (0.80 + rnd() * 0.35); const px = Z.x + Math.cos(ang) * rad, py = Z.y + Math.sin(ang) * rad; if (!validSpot(px, py) || tooCloseAny(px, py, 300)) continue; let blocked = false; if (typeof cityPlan !== 'undefined') { for (const P of cityPlan) { if (P.zone !== zi) continue; const r2 = Math.max(P.w, P.h) * 0.7 + 30; if (dist2(px, py, P.x, P.y) < r2 * r2) { blocked = true; break; } } } if (blocked) continue; spots.push({ x: px, y: py, yaw: ang + Math.PI }); return; // one per district — a skyline of billboards would defeat the point } }); return spots; } function adPlaceBoards() { adBoards = []; if (typeof MAP === 'undefined' || typeof hAt !== 'function') return; // engine not ready yet const MD = (typeof MAPDEFS !== 'undefined' && typeof curMap !== 'undefined' && MAPDEFS[curMap]) || null; srand(((MD && MD.seed || 1337) ^ 0xAD8081) | 1); const farFromSpawns = (x, y, d) => typeof farFromStartZones === 'function' ? farFromStartZones(x, y, d) : dist2(x, y, MAP * SP_LO, MAP * SP_HI) > d * d && dist2(x, y, MAP * SP_HI, MAP * SP_LO) > d * d; const validSpot = (x, y) => { if (x < 60 || y < 60 || x > MAP - 60 || y > MAP - 60) return false; if (!farFromSpawns(x, y, 340)) return false; if (typeof deposits !== 'undefined') for (const D of deposits) if (dist2(x, y, D.x, D.y) < 95 * 95) return false; if (typeof isWalkable === 'function' && !isWalkable(x, y)) return false; const h = hAt(x, y); return h >= 0.40 && h <= 0.75; }; const tooClose = (x, y, d) => { for (const b of adBoards) if (dist2(x, y, b.x, b.y) < d * d) return true; return false; }; let n = 0; for (const s of adScanRoadSpots(validSpot, tooClose)) { if (adBoards.length >= AD_MAX) break; adBoards.push(AdSlot('rd' + (n++), s.x, s.y, s.yaw, 0.88 + rnd() * 0.30)); } n = 0; for (const s of adScanCitySpots(validSpot, tooClose)) { if (adBoards.length >= AD_MAX) break; adBoards.push(AdSlot('cz' + (n++), s.x, s.y, s.yaw, 1.00 + rnd() * 0.34)); } } /* ============================================================================ AD PROVIDER ADAPTER ---------------------------------------------------------------------------- Every board asks THIS interface for what to play, and never touches a video element, a manifest file, or a network SDK directly. Today AD_CONFIG.provider is 'local' and LocalAdProvider serves the bundled clips in assets/ads/. The day a real network deal exists, AD_CONFIG.provider flips to 'network' and NetworkAdProvider — a documented stub below — starts answering the exact same three calls instead. Nothing in placement, rendering, throttling or the settings toggle has to change. ============================================================================ */ class AdProvider { /** Lazy, memoised, idempotent — call it as often as you like. */ init() { if (!this._ready) this._ready = this._doInit().catch(e => { console.warn('adboards: provider init failed', e); }); return this._ready; } async _doInit() {} /** slot: {id, size:{w,h}, placement:'billboard', x, y, yaw}. * Resolves to {id, brand, accent, bg, poster, video} or null for "no * fill" (the slot just keeps showing its neutral plate — exactly what an * unfilled network request would look like too). Reject only for a real * error; "nothing to show" is not one. */ async loadCreative(slot) { return null; } /** Fired once per slot per viewing session (continuously on screen for at * least AD_DWELL_S seconds) — the closest honest proxy for "impression" * without a server round trip. */ reportImpression(slot, creative) {} /** Playback/decoration failures the renderer couldn't route around. */ reportError(slot, err) {} } class LocalAdProvider extends AdProvider { constructor() { super(); this.manifest = null; } async _doInit() { try { const res = await fetch('./assets/ads/manifest.json'); if (!res.ok) throw new Error('http ' + res.status); this.manifest = await res.json(); } catch (e) { console.warn('adboards: local ad manifest unavailable — boards will show a static plate only', e); this.manifest = null; } } async loadCreative(slot) { await this.init(); const list = this.manifest && this.manifest.creatives; if (!list || !list.length) return null; const c = list[adHash(slot.id) % list.length]; return { id: c.id, brand: c.brand, accent: c.accent, bg: c.bg, poster: './assets/ads/' + c.poster, video: c.video ? './assets/ads/' + c.video : null, }; } reportImpression(slot, creative) { AD_STATS.total++; AD_STATS.impressions[creative.id] = (AD_STATS.impressions[creative.id] || 0) + 1; adStatsSave(); } reportError(slot, err) { console.warn('adboards: slot error', slot && slot.id, err); } } /* ---- NetworkAdProvider — STUB ---------------------------------------------- Not implemented on purpose: a live network integration needs a signed agreement, real IDs and — per store policy — a consent flow before it can request anything (see docs/ADS.md). This class exists so the SEAM is concrete rather than hypothetical. To go live: 1. AD_CONFIG.provider = 'network' (below) — the entire call-site change. 2. _doInit(): load the network SDK (e.g. Google Mobile Ads / AdMob, or a raw VAST/IMA tag), initialise it with real app/unit IDs, and gate the whole thing on the consent flow's result — do not request ads before consent is resolved. 3. loadCreative(slot): request a video creative sized to slot.size (an AdMob rewarded/interstitial unit, or a VAST parsed from the tag response) and resolve with the SAME shape LocalAdProvider resolves with: {id, brand, accent, bg, poster, video}. `video`/ `poster` may be blob: or https: URLs — adDrawScreens() only ever consumes them as