Added a capability report when the WebGL map renderer is unavailable
This commit is contained in:
@@ -864,6 +864,38 @@ input:focus, select:focus { border-color: var(--accent); }
|
|||||||
|
|
||||||
.modal-close { padding: 4px 11px; }
|
.modal-close { padding: 4px 11px; }
|
||||||
|
|
||||||
|
/* Capability report: one row per WebGL/WASM feature with a status badge. */
|
||||||
|
.capability-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.capability-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 68px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(110, 175, 255, 0.18);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.capability-pass .capability-status { background: rgba(90, 200, 120, 0.2); color: #8fe0a3; }
|
||||||
|
.capability-fail .capability-status { background: rgba(220, 90, 90, 0.22); color: #ff9a9a; }
|
||||||
|
.capability-optional .capability-status { background: rgba(230, 180, 90, 0.2); color: #ffcf7a; }
|
||||||
|
.capability-info .capability-status { background: rgba(150, 160, 180, 0.18); color: var(--muted); }
|
||||||
|
.capability-label { font-weight: 600; }
|
||||||
|
.capability-detail { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.capability-requirements { margin: 0; padding-left: 40px; list-style: disc; }
|
||||||
|
.capability-requirements li { margin-bottom: 6px; color: var(--muted); font-size: 13px; }
|
||||||
|
/* Let the checks scroll while the card stays inside the viewport. */
|
||||||
|
#capabilities-list { flex: 1 1 auto; min-height: 0; }
|
||||||
|
#capabilities-min { flex: 0 0 auto; max-height: 28vh; }
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* Wrap rather than scroll: every tab stays visible with no clipped edge,
|
/* Wrap rather than scroll: every tab stays visible with no clipped edge,
|
||||||
|
|||||||
@@ -273,6 +273,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Graphics compatibility report: shown when the GPU (WebGL) map
|
||||||
|
renderer is unavailable and the DOM renderer takes over. -->
|
||||||
|
<div id="modal-capabilities" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span id="capabilities-title">Graphics compatibility report</span>
|
||||||
|
<button class="modal-close aero">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="capabilities-summary" class="modal-sub"></div>
|
||||||
|
<div id="capabilities-list" class="modal-body"></div>
|
||||||
|
<div class="modal-sub">Minimum requirements for the GPU map renderer</div>
|
||||||
|
<ul id="capabilities-min" class="modal-body capability-requirements"></ul>
|
||||||
|
<div class="row">
|
||||||
|
<button id="capabilities-ok" class="aero primary">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nation modal: government, technology, diplomacy and budget tabs -->
|
<!-- Nation modal: government, technology, diplomacy and budget tabs -->
|
||||||
<div id="modal-nation" class="modal hidden">
|
<div id="modal-nation" class="modal hidden">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
// Runtime capability report for the WebGL map renderer. The game itself only
|
||||||
|
// needs ordinary DOM APIs: when the GPU map fails to start, MapView falls back
|
||||||
|
// to the DOM renderer and play continues. This module explains *why* the GPU
|
||||||
|
// path is unavailable. It probes every WebGL feature `map_view/webgl.js` uses
|
||||||
|
// (the context, the limits the vertex/fragment programs rely on, the exact
|
||||||
|
// shader programs, the off-screen fog buffer and the two optional extensions)
|
||||||
|
// and confirms that the game does not need WebAssembly at all, so a player
|
||||||
|
// whose map fell back to the DOM renderer gets a concrete list of what is
|
||||||
|
// missing instead of a silent downgrade.
|
||||||
|
//
|
||||||
|
// It never throws: any environment without a DOM (the Node test suite) or
|
||||||
|
// without WebGL is reported as unavailable rather than raising. The probe
|
||||||
|
// deliberately does not call canvas.getContext in environments that have no
|
||||||
|
// WebGL constructors (jsdom), because those would log a not-implemented warning.
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONTEXT_ATTRIBUTES,
|
||||||
|
FOG_FRAGMENT_SHADER,
|
||||||
|
FOG_VERTEX_SHADER,
|
||||||
|
MAP_FRAGMENT_SHADER,
|
||||||
|
MAP_VERTEX_SHADER,
|
||||||
|
} from "./map_view/webgl.js";
|
||||||
|
|
||||||
|
// The terrain program binds these fragment texture units in one draw: the atlas,
|
||||||
|
// grass, sand, forest, mountain, mountain normal, coast and water normal.
|
||||||
|
export const NEEDED_TEXTURE_UNITS = 8;
|
||||||
|
// The terrain vertex layout uses attribute locations 0..6.
|
||||||
|
export const NEEDED_VERTEX_ATTRIBS = 7;
|
||||||
|
// The shipped terrain atlas is 441 px wide, the water normal map 256 px.
|
||||||
|
export const ATLAS_TEXTURE_SIZE = 441;
|
||||||
|
export const WATER_NORMAL_TEXTURE_SIZE = 256;
|
||||||
|
|
||||||
|
// The documented floor for the GPU path. The DOM renderer runs everywhere, so
|
||||||
|
// these are the requirements for the *enhanced* renderer, not to play at all.
|
||||||
|
export const MINIMUM_REQUIREMENTS = [
|
||||||
|
"A browser with JavaScript and either WebGL 2.0 or WebGL 1.0 (WebGL 2.0 recommended).",
|
||||||
|
`At least ${NEEDED_TEXTURE_UNITS} fragment texture units and ${NEEDED_VERTEX_ATTRIBS} vertex attributes (both are inside the WebGL 1.0 guaranteed minimum).`,
|
||||||
|
`A maximum texture size of at least ${ATLAS_TEXTURE_SIZE} px for the default map (one texel per tile is needed for larger maps).`,
|
||||||
|
"No WebGL extensions are required; WEBGL_debug_renderer_info and WEBGL_lose_context are optional.",
|
||||||
|
"No WebAssembly (WASM) support is required; the game contains no WASM.",
|
||||||
|
"Hardware-accelerated WebGL recommended; a software rasteriser works but the game prefers the DOM renderer.",
|
||||||
|
];
|
||||||
|
|
||||||
|
// One row of the report. `ok` is null when a check could not be evaluated.
|
||||||
|
function check(id, label, level, ok, need, value, detail) {
|
||||||
|
return { id, label, level, ok, need, value, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectWebAssembly() {
|
||||||
|
const out = { available: false, validate: false, error: null };
|
||||||
|
if (typeof WebAssembly === "undefined") {
|
||||||
|
out.error = "WebAssembly is not defined in this browser.";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.available = typeof WebAssembly === "object" && WebAssembly !== null;
|
||||||
|
try {
|
||||||
|
if (typeof WebAssembly.validate === "function") {
|
||||||
|
// The eight-byte empty module: a cheap proof the engine can validate WASM.
|
||||||
|
out.validate = WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
out.error = String((error && error.message) || error);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProbeContext(canvas) {
|
||||||
|
const out = { gl: null, version: null, owned: false, error: null };
|
||||||
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||||
|
out.error = "Not running in a browser (no window/document).";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const has2 = typeof window.WebGL2RenderingContext !== "undefined";
|
||||||
|
const has1 = typeof window.WebGLRenderingContext !== "undefined";
|
||||||
|
if (!has2 && !has1) {
|
||||||
|
out.error = "The browser does not expose WebGL (WebGLRenderingContext is undefined).";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const element = canvas || document.createElement("canvas");
|
||||||
|
out.owned = !canvas;
|
||||||
|
try {
|
||||||
|
if (has2) {
|
||||||
|
const gl2 = element.getContext("webgl2", CONTEXT_ATTRIBUTES);
|
||||||
|
if (gl2) {
|
||||||
|
out.gl = gl2;
|
||||||
|
out.version = 2;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gl1 = has1
|
||||||
|
? element.getContext("webgl", CONTEXT_ATTRIBUTES) ||
|
||||||
|
element.getContext("experimental-webgl", CONTEXT_ATTRIBUTES)
|
||||||
|
: null;
|
||||||
|
if (gl1) {
|
||||||
|
out.gl = gl1;
|
||||||
|
out.version = 1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.error = "getContext returned null for webgl2, webgl and experimental-webgl.";
|
||||||
|
} catch (error) {
|
||||||
|
out.error = `getContext threw: ${(error && error.message) || error}`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glInfo(gl) {
|
||||||
|
const info = { vendor: "unknown", renderer: "unknown" };
|
||||||
|
try {
|
||||||
|
const debug = gl.getExtension("WEBGL_debug_renderer_info");
|
||||||
|
info.vendor = String(gl.getParameter(debug ? debug.UNMASKED_VENDOR_WEBGL : gl.VENDOR) || "unknown");
|
||||||
|
info.renderer = String(gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER) || "unknown");
|
||||||
|
} catch {
|
||||||
|
// Some locked-down drivers refuse the query; the fallback labels stay.
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CPU rasterisers known to be slower than the DOM renderer.
|
||||||
|
export function isSoftwareRenderer(renderer) {
|
||||||
|
return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|basic render/i.test(String(renderer || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimLog(log, phase) {
|
||||||
|
const text = String(log || "").replace(/\s+/g, " ").trim();
|
||||||
|
return text ? `${phase}: ${text}` : `${phase} failed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compiles and links one program, then frees every GL object it made. A mock
|
||||||
|
// context in tests can implement just the compile/link surface.
|
||||||
|
function probeProgram(gl, vertexSource, fragmentSource) {
|
||||||
|
let vertex = null;
|
||||||
|
let fragment = null;
|
||||||
|
let program = null;
|
||||||
|
try {
|
||||||
|
vertex = gl.createShader(gl.VERTEX_SHADER);
|
||||||
|
gl.shaderSource(vertex, vertexSource);
|
||||||
|
gl.compileShader(vertex);
|
||||||
|
if (!gl.getShaderParameter(vertex, gl.COMPILE_STATUS)) {
|
||||||
|
return { ok: false, detail: trimLog(gl.getShaderInfoLog(vertex), "vertex shader") };
|
||||||
|
}
|
||||||
|
fragment = gl.createShader(gl.FRAGMENT_SHADER);
|
||||||
|
gl.shaderSource(fragment, fragmentSource);
|
||||||
|
gl.compileShader(fragment);
|
||||||
|
if (!gl.getShaderParameter(fragment, gl.COMPILE_STATUS)) {
|
||||||
|
return { ok: false, detail: trimLog(gl.getShaderInfoLog(fragment), "fragment shader") };
|
||||||
|
}
|
||||||
|
program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vertex);
|
||||||
|
gl.attachShader(program, fragment);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
return { ok: false, detail: trimLog(gl.getProgramInfoLog(program), "link") };
|
||||||
|
}
|
||||||
|
return { ok: true, detail: "compiled and linked" };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: `threw: ${(error && error.message) || error}` };
|
||||||
|
} finally {
|
||||||
|
if (program) gl.deleteProgram(program);
|
||||||
|
if (vertex) gl.deleteShader(vertex);
|
||||||
|
if (fragment) gl.deleteShader(fragment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeFramebuffer(gl) {
|
||||||
|
let texture = null;
|
||||||
|
let fbo = null;
|
||||||
|
try {
|
||||||
|
texture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||||
|
fbo = gl.createFramebuffer();
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
||||||
|
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||||||
|
return {
|
||||||
|
ok: status === gl.FRAMEBUFFER_COMPLETE,
|
||||||
|
detail: status === gl.FRAMEBUFFER_COMPLETE
|
||||||
|
? "RGBA render target is complete"
|
||||||
|
: `framebuffer status 0x${Number(status).toString(16)}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: `threw: ${(error && error.message) || error}` };
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
if (fbo) gl.deleteFramebuffer(fbo);
|
||||||
|
if (texture) gl.deleteTexture(texture);
|
||||||
|
} catch {
|
||||||
|
// A context torn down underneath us: nothing left to free.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameter(gl, name) {
|
||||||
|
try {
|
||||||
|
return Number(gl.getParameter(gl[name])) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExtension(gl, name) {
|
||||||
|
try {
|
||||||
|
return !!gl.getExtension(name);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredTextureSize(mapSize) {
|
||||||
|
const tiles = mapSize
|
||||||
|
? Math.max(Number(mapSize.x) || 0, Number(mapSize.y) || 0)
|
||||||
|
: 0;
|
||||||
|
return Math.max(ATLAS_TEXTURE_SIZE, WATER_NORMAL_TEXTURE_SIZE, tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the report. `options.gl`/`options.version` let tests (and callers that
|
||||||
|
// already hold a context) skip context creation; `options.mapSize` raises the
|
||||||
|
// texture-size requirement to the map's one-texel-per-tile coast field.
|
||||||
|
export function detectCapabilities(options = {}) {
|
||||||
|
const mapSize = options.mapSize || null;
|
||||||
|
const report = {
|
||||||
|
ok: false,
|
||||||
|
renderer: "dom",
|
||||||
|
reason: "",
|
||||||
|
wasm: detectWebAssembly(),
|
||||||
|
webgl: {
|
||||||
|
available: false,
|
||||||
|
version: null,
|
||||||
|
name: null,
|
||||||
|
error: null,
|
||||||
|
info: null,
|
||||||
|
software: false,
|
||||||
|
},
|
||||||
|
checks: [],
|
||||||
|
minRequirements: MINIMUM_REQUIREMENTS.slice(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl = options.gl || null;
|
||||||
|
let version = options.version || null;
|
||||||
|
let owned = false;
|
||||||
|
|
||||||
|
if (!gl) {
|
||||||
|
const probe = createProbeContext(options.canvas);
|
||||||
|
gl = probe.gl;
|
||||||
|
version = probe.version;
|
||||||
|
owned = probe.owned;
|
||||||
|
report.webgl.error = probe.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gl) {
|
||||||
|
report.checks.push(check(
|
||||||
|
"webgl-context",
|
||||||
|
"WebGL context",
|
||||||
|
"required",
|
||||||
|
false,
|
||||||
|
"a webgl2, webgl or experimental-webgl context",
|
||||||
|
"none",
|
||||||
|
report.webgl.error || "The browser did not provide a WebGL context."
|
||||||
|
));
|
||||||
|
report.checks.push(check(
|
||||||
|
"webassembly",
|
||||||
|
"WebAssembly (WASM)",
|
||||||
|
"info",
|
||||||
|
report.wasm.available,
|
||||||
|
"not required",
|
||||||
|
report.wasm.available ? "available" : "unavailable",
|
||||||
|
"The game does not use WebAssembly; this is informational only."
|
||||||
|
));
|
||||||
|
report.reason = "WebGL is unavailable, so the map uses the DOM compatibility renderer.";
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.webgl.available = true;
|
||||||
|
report.webgl.version = version || 1;
|
||||||
|
report.webgl.name = report.webgl.version === 2 ? "WebGL 2" : "WebGL 1";
|
||||||
|
report.webgl.info = glInfo(gl);
|
||||||
|
report.webgl.software = isSoftwareRenderer(report.webgl.info.renderer);
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
checks.push(check(
|
||||||
|
"webgl-context",
|
||||||
|
"WebGL context",
|
||||||
|
"required",
|
||||||
|
true,
|
||||||
|
"a webgl2, webgl or experimental-webgl context",
|
||||||
|
report.webgl.name,
|
||||||
|
`${report.webgl.info.vendor} / ${report.webgl.info.renderer}`
|
||||||
|
));
|
||||||
|
|
||||||
|
const units = parameter(gl, "MAX_TEXTURE_IMAGE_UNITS");
|
||||||
|
checks.push(check(
|
||||||
|
"texture-units",
|
||||||
|
"Fragment texture units",
|
||||||
|
"required",
|
||||||
|
units >= NEEDED_TEXTURE_UNITS,
|
||||||
|
`${NEEDED_TEXTURE_UNITS} (terrain samplers)`,
|
||||||
|
String(units),
|
||||||
|
units >= NEEDED_TEXTURE_UNITS
|
||||||
|
? "Enough samplers for the blended terrain shader."
|
||||||
|
: "Too few samplers for the blended terrain shader."
|
||||||
|
));
|
||||||
|
|
||||||
|
const attribs = parameter(gl, "MAX_VERTEX_ATTRIBS");
|
||||||
|
checks.push(check(
|
||||||
|
"vertex-attribs",
|
||||||
|
"Vertex attributes",
|
||||||
|
"required",
|
||||||
|
attribs >= NEEDED_VERTEX_ATTRIBS,
|
||||||
|
`${NEEDED_VERTEX_ATTRIBS} (terrain vertex layout)`,
|
||||||
|
String(attribs),
|
||||||
|
attribs >= NEEDED_VERTEX_ATTRIBS
|
||||||
|
? "Enough attribute slots for the terrain mesh."
|
||||||
|
: "Too few attribute slots for the terrain mesh."
|
||||||
|
));
|
||||||
|
|
||||||
|
const neededSize = requiredTextureSize(mapSize);
|
||||||
|
const maxSize = parameter(gl, "MAX_TEXTURE_SIZE");
|
||||||
|
checks.push(check(
|
||||||
|
"texture-size",
|
||||||
|
"Maximum texture size",
|
||||||
|
"required",
|
||||||
|
maxSize >= neededSize,
|
||||||
|
`${neededSize} px${mapSize ? " (map/atlas)" : " (atlas)"}`,
|
||||||
|
`${maxSize} px`,
|
||||||
|
maxSize >= neededSize
|
||||||
|
? "The terrain atlas, normal maps and coast field all fit."
|
||||||
|
: "The terrain atlas or coast field will not upload."
|
||||||
|
));
|
||||||
|
|
||||||
|
const terrain = probeProgram(gl, MAP_VERTEX_SHADER, MAP_FRAGMENT_SHADER);
|
||||||
|
checks.push(check(
|
||||||
|
"terrain-program",
|
||||||
|
"Terrain shader program (GLSL ES 1.00)",
|
||||||
|
"required",
|
||||||
|
terrain.ok,
|
||||||
|
"the renderer's terrain program",
|
||||||
|
terrain.ok ? "compiled" : "failed",
|
||||||
|
terrain.detail
|
||||||
|
));
|
||||||
|
|
||||||
|
const fog = probeProgram(gl, FOG_VERTEX_SHADER, FOG_FRAGMENT_SHADER);
|
||||||
|
checks.push(check(
|
||||||
|
"fog-program",
|
||||||
|
"Fog post-process program",
|
||||||
|
"optional",
|
||||||
|
fog.ok,
|
||||||
|
"the renderer's fog blur program",
|
||||||
|
fog.ok ? "compiled" : "failed",
|
||||||
|
fog.ok ? "The fog of war gets its soft blurred edge." : fog.detail
|
||||||
|
));
|
||||||
|
|
||||||
|
const fbo = probeFramebuffer(gl);
|
||||||
|
checks.push(check(
|
||||||
|
"framebuffer",
|
||||||
|
"Off-screen fog buffer (RGBA)",
|
||||||
|
"optional",
|
||||||
|
fbo.ok,
|
||||||
|
"a complete RGBA color attachment",
|
||||||
|
fbo.ok ? "complete" : "unavailable",
|
||||||
|
fbo.ok ? "The fog of war is blurred off-screen before compositing." : fbo.detail
|
||||||
|
));
|
||||||
|
|
||||||
|
const debugInfo = hasExtension(gl, "WEBGL_debug_renderer_info");
|
||||||
|
checks.push(check(
|
||||||
|
"debug-renderer-info",
|
||||||
|
"WEBGL_debug_renderer_info extension",
|
||||||
|
"optional",
|
||||||
|
debugInfo,
|
||||||
|
"not required",
|
||||||
|
debugInfo ? "present" : "absent",
|
||||||
|
debugInfo ? "Real GPU name is reported." : "The generic vendor/renderer is reported instead."
|
||||||
|
));
|
||||||
|
|
||||||
|
const loseContext = hasExtension(gl, "WEBGL_lose_context");
|
||||||
|
checks.push(check(
|
||||||
|
"lose-context",
|
||||||
|
"WEBGL_lose_context extension",
|
||||||
|
"optional",
|
||||||
|
loseContext,
|
||||||
|
"not required",
|
||||||
|
loseContext ? "present" : "absent",
|
||||||
|
loseContext ? "Contexts can be released on restart." : "Restarting leaves the context to the browser."
|
||||||
|
));
|
||||||
|
|
||||||
|
checks.push(check(
|
||||||
|
"webassembly",
|
||||||
|
"WebAssembly (WASM)",
|
||||||
|
"info",
|
||||||
|
report.wasm.available,
|
||||||
|
"not required",
|
||||||
|
report.wasm.available ? "available" : "unavailable",
|
||||||
|
"The game does not use WebAssembly; this is informational only."
|
||||||
|
));
|
||||||
|
|
||||||
|
report.checks = checks;
|
||||||
|
report.ok = checks.every((entry) => entry.level !== "required" || entry.ok);
|
||||||
|
report.renderer = report.ok ? (report.webgl.version === 2 ? "webgl2" : "webgl") : "dom";
|
||||||
|
if (!report.ok) {
|
||||||
|
report.reason = "WebGL is present but a required feature is missing, so the map uses the DOM compatibility renderer.";
|
||||||
|
} else if (report.webgl.software) {
|
||||||
|
report.reason = "WebGL is only available through a software rasteriser, so the map uses the DOM compatibility renderer.";
|
||||||
|
} else {
|
||||||
|
report.reason = "WebGL is fully supported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owned) {
|
||||||
|
try {
|
||||||
|
const lose = gl.getExtension("WEBGL_lose_context");
|
||||||
|
if (lose) lose.loseContext();
|
||||||
|
} catch {
|
||||||
|
// The probe context is disposable either way.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capabilityRendererLabel(renderer) {
|
||||||
|
if (renderer === "webgl2") return "WebGL 2 (GPU)";
|
||||||
|
if (renderer === "webgl") return "WebGL 1 (GPU)";
|
||||||
|
return "DOM (compatibility mode)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS / FAIL / OPTIONAL / INFO, for a colour-coded row.
|
||||||
|
export function capabilityStatus(entry) {
|
||||||
|
if (entry.level === "info") return "INFO";
|
||||||
|
if (entry.ok) return "PASS";
|
||||||
|
if (entry.level === "required") return "FAIL";
|
||||||
|
return "OPTIONAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCapabilityReport(report) {
|
||||||
|
if (!report.webgl.available) {
|
||||||
|
return "No WebGL context could be created, so the map is drawn with the DOM "
|
||||||
|
+ "compatibility renderer. The game is fully playable; only the GPU map is off.";
|
||||||
|
}
|
||||||
|
if (!report.ok) {
|
||||||
|
return "WebGL is present but is missing a required feature, so the map is drawn "
|
||||||
|
+ "with the DOM compatibility renderer. The game is fully playable; only the GPU map is off.";
|
||||||
|
}
|
||||||
|
if (report.webgl.software) {
|
||||||
|
return "WebGL is only available through a software rasteriser, so the map is drawn "
|
||||||
|
+ "with the DOM compatibility renderer for a steady frame rate.";
|
||||||
|
}
|
||||||
|
return "WebGL is fully supported.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain-text report for the developer console (and for tests).
|
||||||
|
export function formatCapabilityReport(report) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push("Battle for 'Tismo — client capability report");
|
||||||
|
lines.push(summarizeCapabilityReport(report));
|
||||||
|
lines.push(`GPU map renderer: ${capabilityRendererLabel(report.renderer)}`);
|
||||||
|
lines.push(`WebGL: ${report.webgl.available ? report.webgl.name : "unavailable"}`);
|
||||||
|
if (report.webgl.info) {
|
||||||
|
lines.push(`GPU: ${report.webgl.info.vendor} / ${report.webgl.info.renderer}` +
|
||||||
|
(report.webgl.software ? " (software rasteriser)" : ""));
|
||||||
|
}
|
||||||
|
lines.push(`WebAssembly: ${report.wasm.available ? "available" : "unavailable"} (not used by the game)`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Features used by the GPU map:");
|
||||||
|
for (const entry of report.checks) {
|
||||||
|
lines.push(` [${capabilityStatus(entry)}] ${entry.label}: need ${entry.need}; have ${entry.value}`);
|
||||||
|
if (!entry.ok && entry.detail) lines.push(` ${entry.detail}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Minimum requirements for the GPU map renderer:");
|
||||||
|
for (const requirement of report.minRequirements) lines.push(` - ${requirement}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
// snapshot plumbing and the order flow.
|
// snapshot plumbing and the order flow.
|
||||||
|
|
||||||
import { MapView } from "./map_view.js";
|
import { MapView } from "./map_view.js";
|
||||||
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js";
|
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal, CapabilityModal } from "./modals.js";
|
||||||
import { GameFeed } from "./feed.js";
|
import { GameFeed } from "./feed.js";
|
||||||
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
||||||
import { bootLog } from "./loading.js";
|
import { bootLog } from "./loading.js";
|
||||||
|
import { formatCapabilityReport } from "./capabilities.js";
|
||||||
import { panelMethods } from "./game_screen/panels.js";
|
import { panelMethods } from "./game_screen/panels.js";
|
||||||
|
|
||||||
export class GameScreen {
|
export class GameScreen {
|
||||||
@@ -48,6 +49,9 @@ export class GameScreen {
|
|||||||
this._news = [];
|
this._news = [];
|
||||||
this._seenNews = null;
|
this._seenNews = null;
|
||||||
this._entered = false;
|
this._entered = false;
|
||||||
|
// The capability report is shown at most once per join, when the GPU map
|
||||||
|
// renderer was not used (see `_reportCapabilities`).
|
||||||
|
this._capabilitiesReported = false;
|
||||||
this._lastFrame = 0;
|
this._lastFrame = 0;
|
||||||
this._fpsFrames = 0;
|
this._fpsFrames = 0;
|
||||||
this._fpsElapsed = 0;
|
this._fpsElapsed = 0;
|
||||||
@@ -72,6 +76,7 @@ export class GameScreen {
|
|||||||
this.confirmModal = new ConfirmModal();
|
this.confirmModal = new ConfirmModal();
|
||||||
this.newsModal = new NewsModal();
|
this.newsModal = new NewsModal();
|
||||||
this.mapModesModal = new MapModesModal();
|
this.mapModesModal = new MapModesModal();
|
||||||
|
this.capabilityModal = new CapabilityModal();
|
||||||
this.feed = new GameFeed($("#game-feed"));
|
this.feed = new GameFeed($("#game-feed"));
|
||||||
this.$stackMenu = $("#stack-menu");
|
this.$stackMenu = $("#stack-menu");
|
||||||
this.$statTooltip = $("#stat-tooltip");
|
this.$statTooltip = $("#stat-tooltip");
|
||||||
@@ -199,10 +204,27 @@ export class GameScreen {
|
|||||||
if (this._loading) {
|
if (this._loading) {
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
bootLog.step("Updated the HUD");
|
bootLog.step("Updated the HUD");
|
||||||
bootLog.finish();
|
bootLog.finish(() => this._reportCapabilities(state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the GPU map renderer was not used, log and show the detailed list of
|
||||||
|
// WebGL/WASM features this client is missing. The report is built only on the
|
||||||
|
// fallback path (it probes a throwaway context) and shown at most once.
|
||||||
|
_reportCapabilities(state) {
|
||||||
|
if (this._capabilitiesReported || !this._entered) return;
|
||||||
|
// A forced "dom" mode (tests, benchmark) is a deliberate choice, not a
|
||||||
|
// compatibility problem, so it is never reported.
|
||||||
|
if (this.map.rendererMode === "dom" || this.map.glRenderer) return;
|
||||||
|
this._capabilitiesReported = true;
|
||||||
|
const mapSize = state && state.mapConfig ? state.mapConfig.mapSize : null;
|
||||||
|
const report = this.map.capabilityReport(mapSize);
|
||||||
|
if (typeof console !== "undefined" && console.warn) {
|
||||||
|
console.warn(formatCapabilityReport(report));
|
||||||
|
}
|
||||||
|
this.capabilityModal.show(report);
|
||||||
|
}
|
||||||
|
|
||||||
_bindMap() {
|
_bindMap() {
|
||||||
this.map.onUnitSelected = (id) => {
|
this.map.onUnitSelected = (id) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|||||||
+13
-3
@@ -55,23 +55,33 @@ export class LoadingLog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish() {
|
// `onHidden` runs once the overlay has actually left the screen, so callers
|
||||||
if (!this.active) return;
|
// can show something (the capability report) without it landing behind the
|
||||||
|
// fading loading panel. It fires at once when the log was never bound or was
|
||||||
|
// already finished.
|
||||||
|
finish(onHidden) {
|
||||||
|
if (!this.active) {
|
||||||
|
if (onHidden) onHidden();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.step("Ready");
|
this.step("Ready");
|
||||||
this._line(now() - this.startedAt, "Total map setup", true);
|
this._line(now() - this.startedAt, "Total map setup", true);
|
||||||
this.active = false;
|
this.active = false;
|
||||||
|
const notify = () => { if (onHidden) onHidden(); };
|
||||||
if (this.$root) {
|
if (this.$root) {
|
||||||
const $root = this.$root;
|
const $root = this.$root;
|
||||||
$root.addClass("done");
|
$root.addClass("done");
|
||||||
// Fade out after a beat so the last lines can be read. Hiding is deferred
|
// Fade out after a beat so the last lines can be read. Hiding is deferred
|
||||||
// through requestAnimationFrame, which the test harness stubs out, so a
|
// through requestAnimationFrame, which the test harness stubs out, so a
|
||||||
// finished log never touches the DOM after the test window is closed.
|
// finished log never touches the DOM after the test window is closed.
|
||||||
const hide = () => $root.addClass("hidden");
|
const hide = () => { $root.addClass("hidden"); notify(); };
|
||||||
if (typeof requestAnimationFrame === "function") {
|
if (typeof requestAnimationFrame === "function") {
|
||||||
setTimeout(() => requestAnimationFrame(hide), 2500);
|
setTimeout(() => requestAnimationFrame(hide), 2500);
|
||||||
} else {
|
} else {
|
||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { inputMethods } from "./map_view/input.js";
|
|||||||
import { politicalMethods } from "./map_view/political.js";
|
import { politicalMethods } from "./map_view/political.js";
|
||||||
import { modeMethods } from "./map_view/modes.js";
|
import { modeMethods } from "./map_view/modes.js";
|
||||||
import { createGLMapRenderer } from "./map_view/webgl.js";
|
import { createGLMapRenderer } from "./map_view/webgl.js";
|
||||||
|
import { detectCapabilities } from "./capabilities.js";
|
||||||
import { CAMERA_TILT } from "./map_view/constants.js";
|
import { CAMERA_TILT } from "./map_view/constants.js";
|
||||||
|
|
||||||
// Re-exported for tests and callers that use the border geometry directly.
|
// Re-exported for tests and callers that use the border geometry directly.
|
||||||
@@ -190,6 +191,12 @@ export class MapView {
|
|||||||
this.glRenderer = null;
|
this.glRenderer = null;
|
||||||
this.glCanvas = null;
|
this.glCanvas = null;
|
||||||
this.rendererMode = options.renderer || "auto";
|
this.rendererMode = options.renderer || "auto";
|
||||||
|
// Set when the GPU renderer is not used: "webgl-unavailable" (no context, a
|
||||||
|
// shader/limit failure) or "software" (a CPU rasteriser, slower than DOM).
|
||||||
|
this.rendererFallback = null;
|
||||||
|
// Lazily built capability report for the fallback notice (see
|
||||||
|
// `capabilityReport`); null until asked for.
|
||||||
|
this.capabilities = null;
|
||||||
if (this.rendererMode !== "dom") this._setupGLRenderer();
|
if (this.rendererMode !== "dom") this._setupGLRenderer();
|
||||||
|
|
||||||
this._setupCameraInput();
|
this._setupCameraInput();
|
||||||
@@ -207,6 +214,7 @@ export class MapView {
|
|||||||
const renderer = createGLMapRenderer(canvas);
|
const renderer = createGLMapRenderer(canvas);
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||||
|
this.rendererFallback = "webgl-unavailable";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A CPU rasteriser (no usable GPU) can be slower than the DOM renderer.
|
// A CPU rasteriser (no usable GPU) can be slower than the DOM renderer.
|
||||||
@@ -214,6 +222,7 @@ export class MapView {
|
|||||||
if (this.rendererMode === "auto" && renderer.softwareRenderer()) {
|
if (this.rendererMode === "auto" && renderer.softwareRenderer()) {
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||||
|
this.rendererFallback = "software";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.glRenderer = renderer;
|
this.glRenderer = renderer;
|
||||||
@@ -221,6 +230,19 @@ export class MapView {
|
|||||||
this.$world.addClass("gl-render");
|
this.$world.addClass("gl-render");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A detailed, cached report of every WebGL/WASM feature this client needs.
|
||||||
|
// Only meaningful when the GPU renderer was not used; it probes a throwaway
|
||||||
|
// context, so it is built on demand (when the fallback notice appears) rather
|
||||||
|
// than in the constructor. `mapSize` sharpens the texture-size requirement to
|
||||||
|
// the map being played; it falls back to the world already built, if any.
|
||||||
|
capabilityReport(mapSize) {
|
||||||
|
if (!this.capabilities) {
|
||||||
|
const size = mapSize || (this.mapConfig && this.mapConfig.mapSize) || null;
|
||||||
|
this.capabilities = detectCapabilities({ mapSize: size });
|
||||||
|
}
|
||||||
|
return this.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
// Draws the map canvas from the current view state, if WebGL is active. Safe
|
// Draws the map canvas from the current view state, if WebGL is active. Safe
|
||||||
// to call every frame: the renderer skips untouched camera/content state.
|
// to call every frame: the renderer skips untouched camera/content state.
|
||||||
_glRender() {
|
_glRender() {
|
||||||
|
|||||||
+26
-13
@@ -87,6 +87,20 @@ const FOG_FLOATS = 7;
|
|||||||
// A flat sample, used when a view has no relief field (tests, stub views).
|
// A flat sample, used when a view has no relief field (tests, stub views).
|
||||||
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
||||||
|
|
||||||
|
// The context attributes every map context is created with. Kept here so the
|
||||||
|
// capability probe (client/js/capabilities.js) requests exactly the same context
|
||||||
|
// the renderer would, instead of a subtly different one.
|
||||||
|
export const CONTEXT_ATTRIBUTES = {
|
||||||
|
alpha: true,
|
||||||
|
antialias: false,
|
||||||
|
depth: false,
|
||||||
|
stencil: false,
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
failIfMajorPerformanceCaveat: false,
|
||||||
|
};
|
||||||
|
|
||||||
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
||||||
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
||||||
// lets the political/economic modes skip the textured terrain mesh entirely.
|
// lets the political/economic modes skip the textured terrain mesh entirely.
|
||||||
@@ -317,6 +331,15 @@ void main() {
|
|||||||
// spreads over about twenty-four.
|
// spreads over about twenty-four.
|
||||||
const FOG_BLUR_STEP = 3.0;
|
const FOG_BLUR_STEP = 3.0;
|
||||||
|
|
||||||
|
// The exact programs the renderer compiles, exported so the capability probe can
|
||||||
|
// test GLSL ES support against the real shaders rather than a stand-in.
|
||||||
|
export {
|
||||||
|
VERTEX_SHADER as MAP_VERTEX_SHADER,
|
||||||
|
FRAGMENT_SHADER as MAP_FRAGMENT_SHADER,
|
||||||
|
FS_VERTEX_SHADER as FOG_VERTEX_SHADER,
|
||||||
|
FS_FRAGMENT_SHADER as FOG_FRAGMENT_SHADER,
|
||||||
|
};
|
||||||
|
|
||||||
// Builds a renderer on the given canvas, or null when no WebGL context can be
|
// Builds a renderer on the given canvas, or null when no WebGL context can be
|
||||||
// created. The availability check is done without touching canvas.getContext in
|
// created. The availability check is done without touching canvas.getContext in
|
||||||
// environments (jsdom) that would throw and log.
|
// environments (jsdom) that would throw and log.
|
||||||
@@ -392,20 +415,10 @@ export class GLMapRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initContext() {
|
_initContext() {
|
||||||
const attributes = {
|
|
||||||
alpha: true,
|
|
||||||
antialias: false,
|
|
||||||
depth: false,
|
|
||||||
stencil: false,
|
|
||||||
premultipliedAlpha: false,
|
|
||||||
preserveDrawingBuffer: false,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
failIfMajorPerformanceCaveat: false,
|
|
||||||
};
|
|
||||||
const gl =
|
const gl =
|
||||||
this.canvas.getContext("webgl2", attributes) ||
|
this.canvas.getContext("webgl2", CONTEXT_ATTRIBUTES) ||
|
||||||
this.canvas.getContext("webgl", attributes) ||
|
this.canvas.getContext("webgl", CONTEXT_ATTRIBUTES) ||
|
||||||
this.canvas.getContext("experimental-webgl", attributes);
|
this.canvas.getContext("experimental-webgl", CONTEXT_ATTRIBUTES);
|
||||||
if (!gl) throw new Error("no WebGL context");
|
if (!gl) throw new Error("no WebGL context");
|
||||||
this.gl = gl;
|
this.gl = gl;
|
||||||
this.isWebGL2 = typeof window !== "undefined" &&
|
this.isWebGL2 = typeof window !== "undefined" &&
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export { NationModal } from "./modals/nation.js";
|
|||||||
export { ConfirmModal } from "./modals/confirm.js";
|
export { ConfirmModal } from "./modals/confirm.js";
|
||||||
export { NewsModal } from "./modals/news.js";
|
export { NewsModal } from "./modals/news.js";
|
||||||
export { MapModesModal } from "./modals/map_modes.js";
|
export { MapModesModal } from "./modals/map_modes.js";
|
||||||
|
export { CapabilityModal } from "./modals/capabilities.js";
|
||||||
export { describeNews } from "./modals/format.js";
|
export { describeNews } from "./modals/format.js";
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// The graphics compatibility report. Shown when the GPU map renderer could not
|
||||||
|
// be used, so the player sees exactly which WebGL (and WebAssembly) features
|
||||||
|
// this client is missing rather than a silent downgrade to the DOM renderer.
|
||||||
|
// The report itself is built by `client/js/capabilities.js`.
|
||||||
|
|
||||||
|
import { capabilityStatus, summarizeCapabilityReport } from "../capabilities.js";
|
||||||
|
|
||||||
|
export class CapabilityModal {
|
||||||
|
constructor() {
|
||||||
|
this.$modal = $("#modal-capabilities");
|
||||||
|
this.$summary = $("#capabilities-summary");
|
||||||
|
this.$list = $("#capabilities-list");
|
||||||
|
this.$min = $("#capabilities-min");
|
||||||
|
this.$modal.find(".modal-close").on("click", () => this.close());
|
||||||
|
this.$modal.find("#capabilities-ok").on("click", () => this.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
isShowing() {
|
||||||
|
return !this.$modal.hasClass("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
show(report) {
|
||||||
|
this.$summary.text(summarizeCapabilityReport(report));
|
||||||
|
this.$list.empty();
|
||||||
|
for (const entry of report.checks) this.$list.append(this._row(entry));
|
||||||
|
this.$min.empty();
|
||||||
|
for (const requirement of report.minRequirements) {
|
||||||
|
this.$min.append($("<li></li>").text(requirement));
|
||||||
|
}
|
||||||
|
this.$modal.removeClass("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.$modal.addClass("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
_row(entry) {
|
||||||
|
const status = capabilityStatus(entry);
|
||||||
|
let detail = `Need ${entry.need}; have ${entry.value}`;
|
||||||
|
if (entry.detail) detail += ` — ${entry.detail}`;
|
||||||
|
const $row = $("<div></div>").addClass(`capability-row capability-${status.toLowerCase()}`);
|
||||||
|
$row.append($("<span></span>").addClass("capability-status").text(status));
|
||||||
|
const $text = $("<div></div>").addClass("capability-text");
|
||||||
|
$text.append($("<div></div>").addClass("capability-label").text(entry.label));
|
||||||
|
$text.append($("<div></div>").addClass("capability-detail").text(detail));
|
||||||
|
$row.append($text);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { TestCase } from "./framework/test_case.js";
|
||||||
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||||
|
import { GameScreen } from "../client/js/game_screen.js";
|
||||||
|
import { CIVILISATIONS } from "../shared/data.js";
|
||||||
|
import { smallState } from "./framework/helpers.js";
|
||||||
|
import {
|
||||||
|
NEEDED_TEXTURE_UNITS,
|
||||||
|
NEEDED_VERTEX_ATTRIBS,
|
||||||
|
capabilityStatus,
|
||||||
|
detectCapabilities,
|
||||||
|
detectWebAssembly,
|
||||||
|
formatCapabilityReport,
|
||||||
|
summarizeCapabilityReport,
|
||||||
|
} from "../client/js/capabilities.js";
|
||||||
|
import { CapabilityModal } from "../client/js/modals/capabilities.js";
|
||||||
|
|
||||||
|
// A stand-in WebGL context that answers exactly the surface `detectCapabilities`
|
||||||
|
// probes, so the checks can be exercised without a GPU.
|
||||||
|
function createMockGL(overrides = {}) {
|
||||||
|
const constants = {
|
||||||
|
VERTEX_SHADER: 0x8b31,
|
||||||
|
FRAGMENT_SHADER: 0x8b30,
|
||||||
|
COMPILE_STATUS: 0x8b81,
|
||||||
|
LINK_STATUS: 0x8b82,
|
||||||
|
MAX_TEXTURE_IMAGE_UNITS: 0x8872,
|
||||||
|
MAX_VERTEX_ATTRIBS: 0x8869,
|
||||||
|
MAX_TEXTURE_SIZE: 0x0d33,
|
||||||
|
VENDOR: 0x1f00,
|
||||||
|
RENDERER: 0x1f01,
|
||||||
|
TEXTURE_2D: 0x0de1,
|
||||||
|
RGBA: 0x1908,
|
||||||
|
UNSIGNED_BYTE: 0x1401,
|
||||||
|
FRAMEBUFFER: 0x8d40,
|
||||||
|
COLOR_ATTACHMENT0: 0x8ce0,
|
||||||
|
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
||||||
|
};
|
||||||
|
const parameters = {
|
||||||
|
[constants.MAX_TEXTURE_IMAGE_UNITS]: overrides.textureUnits ?? 16,
|
||||||
|
[constants.MAX_VERTEX_ATTRIBS]: overrides.vertexAttribs ?? 16,
|
||||||
|
[constants.MAX_TEXTURE_SIZE]: overrides.maxTextureSize ?? 8192,
|
||||||
|
[constants.VENDOR]: overrides.vendor ?? "Test Vendor",
|
||||||
|
[constants.RENDERER]: overrides.renderer ?? "Test GPU",
|
||||||
|
};
|
||||||
|
const extensions = overrides.extensions || {};
|
||||||
|
const compileOk = !overrides.compileFails;
|
||||||
|
const gl = {
|
||||||
|
...constants,
|
||||||
|
getParameter: (name) => (name in parameters ? parameters[name] : null),
|
||||||
|
getExtension: (name) => extensions[name] || null,
|
||||||
|
createShader: () => ({ shader: true }),
|
||||||
|
shaderSource: () => {},
|
||||||
|
compileShader: () => {},
|
||||||
|
getShaderParameter: () => compileOk,
|
||||||
|
getShaderInfoLog: () => (compileOk ? "" : "ERROR: 0:1: sampler limit exceeded"),
|
||||||
|
deleteShader: () => {},
|
||||||
|
createProgram: () => ({ program: true }),
|
||||||
|
attachShader: () => {},
|
||||||
|
linkProgram: () => {},
|
||||||
|
getProgramParameter: () => true,
|
||||||
|
getProgramInfoLog: () => "",
|
||||||
|
deleteProgram: () => {},
|
||||||
|
createTexture: () => ({ texture: true }),
|
||||||
|
bindTexture: () => {},
|
||||||
|
texImage2D: () => {},
|
||||||
|
deleteTexture: () => {},
|
||||||
|
createFramebuffer: () => ({ fbo: true }),
|
||||||
|
bindFramebuffer: () => {},
|
||||||
|
framebufferTexture2D: () => {},
|
||||||
|
checkFramebufferStatus: () => constants.FRAMEBUFFER_COMPLETE,
|
||||||
|
deleteFramebuffer: () => {},
|
||||||
|
};
|
||||||
|
if (overrides.framebufferFails) gl.checkFramebufferStatus = () => 0x8cd6;
|
||||||
|
return gl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkById(report, id) {
|
||||||
|
return report.checks.find((entry) => entry.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CapabilitiesTest extends TestCase {
|
||||||
|
async test_missing_context_reports_the_dom_fallback_and_requirements() {
|
||||||
|
const env = await setupDom();
|
||||||
|
try {
|
||||||
|
const report = detectCapabilities();
|
||||||
|
this.assertFalse(report.webgl.available, "WebGL is reported unavailable");
|
||||||
|
this.assertEqual(report.renderer, "dom", "the DOM renderer is the verdict");
|
||||||
|
this.assertFalse(checkById(report, "webgl-context").ok, "the context check fails");
|
||||||
|
this.assertFalse(report.ok, "a missing context is not a supported report");
|
||||||
|
this.assertNotEmpty(report.minRequirements, "the requirements are listed");
|
||||||
|
this.assertTrue(
|
||||||
|
report.reason.includes("WebGL"),
|
||||||
|
"the reason names the missing GPU path"
|
||||||
|
);
|
||||||
|
const text = formatCapabilityReport(report);
|
||||||
|
this.assertTrue(text.includes("WebAssembly"), "the report mentions WebAssembly");
|
||||||
|
this.assertTrue(text.includes("Minimum requirements"), "the report lists the minimums");
|
||||||
|
} finally {
|
||||||
|
teardownDom(env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_supported_context_passes_every_required_check() {
|
||||||
|
const report = detectCapabilities({ gl: createMockGL(), version: 2 });
|
||||||
|
this.assertTrue(report.webgl.available, "the context is available");
|
||||||
|
this.assertEqual(report.webgl.name, "WebGL 2", "the version is reported");
|
||||||
|
this.assertTrue(report.ok, "all required checks pass");
|
||||||
|
this.assertEqual(report.renderer, "webgl2", "the GPU verdict is used");
|
||||||
|
for (const entry of report.checks) {
|
||||||
|
if (entry.level === "required") this.assertTrue(entry.ok, `${entry.id} should pass`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_too_few_texture_units_makes_a_required_check_fail() {
|
||||||
|
const report = detectCapabilities({
|
||||||
|
gl: createMockGL({ textureUnits: NEEDED_TEXTURE_UNITS - 1 }),
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const units = checkById(report, "texture-units");
|
||||||
|
this.assertFalse(units.ok, "the texture-unit check fails");
|
||||||
|
this.assertFalse(report.ok, "the report is not supported");
|
||||||
|
this.assertEqual(report.renderer, "dom", "the DOM renderer is the verdict");
|
||||||
|
this.assertEqual(capabilityStatus(units), "FAIL", "the row is marked FAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_too_few_vertex_attributes_fails() {
|
||||||
|
const report = detectCapabilities({
|
||||||
|
gl: createMockGL({ vertexAttribs: NEEDED_VERTEX_ATTRIBS - 1 }),
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
this.assertFalse(checkById(report, "vertex-attribs").ok, "the attribute check fails");
|
||||||
|
this.assertFalse(report.ok, "the report is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_shader_compile_failure_is_reported_in_the_detail() {
|
||||||
|
const report = detectCapabilities({ gl: createMockGL({ compileFails: true }), version: 1 });
|
||||||
|
const shader = checkById(report, "terrain-program");
|
||||||
|
this.assertFalse(shader.ok, "the terrain program check fails");
|
||||||
|
this.assertTrue(shader.detail.includes("sampler limit"), "the compiler log is surfaced");
|
||||||
|
this.assertFalse(report.ok, "a broken shader blocks the GPU path");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_software_rasteriser_is_flagged_but_still_passes_required_checks() {
|
||||||
|
const report = detectCapabilities({
|
||||||
|
gl: createMockGL({ renderer: "SwiftShader Device (Subzero)" }),
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
this.assertTrue(report.webgl.software, "the CPU rasteriser is detected");
|
||||||
|
this.assertTrue(report.ok, "the required checks still pass");
|
||||||
|
this.assertTrue(
|
||||||
|
summarizeCapabilityReport(report).includes("software"),
|
||||||
|
"the summary explains the downgrade"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_optional_failures_do_not_block_the_gpu_path() {
|
||||||
|
const report = detectCapabilities({
|
||||||
|
gl: createMockGL({ extensions: {}, framebufferFails: true, compileFails: false }),
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
this.assertFalse(checkById(report, "framebuffer").ok, "the optional buffer check can fail");
|
||||||
|
this.assertEqual(capabilityStatus(checkById(report, "framebuffer")), "OPTIONAL", "it is optional");
|
||||||
|
this.assertTrue(report.ok, "optional gaps keep the GPU path");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_wasm_is_reported_as_informational_only() {
|
||||||
|
const wasm = detectWebAssembly();
|
||||||
|
this.assertTrue(wasm.available, "Node/browsers expose WebAssembly");
|
||||||
|
const report = detectCapabilities({ gl: createMockGL(), version: 1 });
|
||||||
|
const entry = checkById(report, "webassembly");
|
||||||
|
this.assertEqual(entry.level, "info", "WASM is informational, not required");
|
||||||
|
this.assertEqual(capabilityStatus(entry), "INFO", "and it is never marked FAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_format_lists_every_check_and_requirement() {
|
||||||
|
const report = detectCapabilities({ gl: createMockGL(), version: 2 });
|
||||||
|
const text = formatCapabilityReport(report);
|
||||||
|
for (const entry of report.checks) {
|
||||||
|
this.assertTrue(text.includes(entry.label), `the report lists ${entry.id}`);
|
||||||
|
}
|
||||||
|
for (const requirement of report.minRequirements) {
|
||||||
|
this.assertTrue(text.includes(requirement), "the report lists every requirement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_modal_renders_a_row_per_check_and_the_requirements() {
|
||||||
|
const env = await setupDom();
|
||||||
|
try {
|
||||||
|
const report = detectCapabilities();
|
||||||
|
const modal = new CapabilityModal();
|
||||||
|
this.assertFalse(modal.isShowing(), "the modal starts hidden");
|
||||||
|
modal.show(report);
|
||||||
|
this.assertTrue(modal.isShowing(), "the modal is shown");
|
||||||
|
this.assertEqual(
|
||||||
|
env.$("#capabilities-list .capability-row").length,
|
||||||
|
report.checks.length,
|
||||||
|
"one row per check"
|
||||||
|
);
|
||||||
|
this.assertEqual(
|
||||||
|
env.$("#capabilities-min li").length,
|
||||||
|
report.minRequirements.length,
|
||||||
|
"one line per requirement"
|
||||||
|
);
|
||||||
|
this.assertNotEmpty(env.$("#capabilities-summary").text(), "the summary is filled in");
|
||||||
|
modal.close();
|
||||||
|
this.assertFalse(modal.isShowing(), "the modal can be closed");
|
||||||
|
} finally {
|
||||||
|
teardownDom(env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async test_joining_without_webgl_surfaces_the_report_once() {
|
||||||
|
const env = await setupDom();
|
||||||
|
const warnings = [];
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...args) => warnings.push(args.join(" "));
|
||||||
|
try {
|
||||||
|
const network = { sendOrder() {}, sendGameSetup() {} };
|
||||||
|
const config = {
|
||||||
|
playerName: "Carol",
|
||||||
|
playerCivId: "france",
|
||||||
|
civilisations: [CIVILISATIONS[0], CIVILISATIONS[1]],
|
||||||
|
};
|
||||||
|
const screen = new GameScreen(network, config);
|
||||||
|
screen.enter();
|
||||||
|
this.assertEqual(
|
||||||
|
screen.map.rendererFallback,
|
||||||
|
"webgl-unavailable",
|
||||||
|
"the GPU path is known to be down"
|
||||||
|
);
|
||||||
|
this.assertNull(screen.map.glRenderer, "jsdom has no GPU renderer");
|
||||||
|
|
||||||
|
const snapshot = smallState().snapshot(0);
|
||||||
|
screen.onState(snapshot);
|
||||||
|
this.assertFalse(screen.capabilityModal.isShowing(), "nothing shows before the report runs");
|
||||||
|
|
||||||
|
screen._reportCapabilities(snapshot);
|
||||||
|
this.assertTrue(screen.capabilityModal.isShowing(), "the report modal opens on join");
|
||||||
|
this.assertGreater(
|
||||||
|
env.$("#capabilities-list .capability-row").length,
|
||||||
|
0,
|
||||||
|
"it lists the features"
|
||||||
|
);
|
||||||
|
this.assertTrue(
|
||||||
|
warnings.some((line) => line.includes("capability report")),
|
||||||
|
"the report is also logged to the console"
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.capabilityModal.close();
|
||||||
|
screen._reportCapabilities(snapshot);
|
||||||
|
this.assertFalse(screen.capabilityModal.isShowing(), "the report is shown at most once");
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
teardownDom(env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user