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; }
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
/* Wrap rather than scroll: every tab stays visible with no clipped edge,
|
||||
|
||||
@@ -273,6 +273,24 @@
|
||||
</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 -->
|
||||
<div id="modal-nation" class="modal hidden">
|
||||
<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.
|
||||
|
||||
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 { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
||||
import { bootLog } from "./loading.js";
|
||||
import { formatCapabilityReport } from "./capabilities.js";
|
||||
import { panelMethods } from "./game_screen/panels.js";
|
||||
|
||||
export class GameScreen {
|
||||
@@ -48,6 +49,9 @@ export class GameScreen {
|
||||
this._news = [];
|
||||
this._seenNews = null;
|
||||
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._fpsFrames = 0;
|
||||
this._fpsElapsed = 0;
|
||||
@@ -72,6 +76,7 @@ export class GameScreen {
|
||||
this.confirmModal = new ConfirmModal();
|
||||
this.newsModal = new NewsModal();
|
||||
this.mapModesModal = new MapModesModal();
|
||||
this.capabilityModal = new CapabilityModal();
|
||||
this.feed = new GameFeed($("#game-feed"));
|
||||
this.$stackMenu = $("#stack-menu");
|
||||
this.$statTooltip = $("#stat-tooltip");
|
||||
@@ -199,10 +204,27 @@ export class GameScreen {
|
||||
if (this._loading) {
|
||||
this._loading = false;
|
||||
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() {
|
||||
this.map.onUnitSelected = (id) => {
|
||||
if (!id) {
|
||||
|
||||
+13
-3
@@ -55,23 +55,33 @@ export class LoadingLog {
|
||||
}
|
||||
}
|
||||
|
||||
finish() {
|
||||
if (!this.active) return;
|
||||
// `onHidden` runs once the overlay has actually left the screen, so callers
|
||||
// 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._line(now() - this.startedAt, "Total map setup", true);
|
||||
this.active = false;
|
||||
const notify = () => { if (onHidden) onHidden(); };
|
||||
if (this.$root) {
|
||||
const $root = this.$root;
|
||||
$root.addClass("done");
|
||||
// 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
|
||||
// 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") {
|
||||
setTimeout(() => requestAnimationFrame(hide), 2500);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
} else {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { inputMethods } from "./map_view/input.js";
|
||||
import { politicalMethods } from "./map_view/political.js";
|
||||
import { modeMethods } from "./map_view/modes.js";
|
||||
import { createGLMapRenderer } from "./map_view/webgl.js";
|
||||
import { detectCapabilities } from "./capabilities.js";
|
||||
import { CAMERA_TILT } from "./map_view/constants.js";
|
||||
|
||||
// Re-exported for tests and callers that use the border geometry directly.
|
||||
@@ -190,6 +191,12 @@ export class MapView {
|
||||
this.glRenderer = null;
|
||||
this.glCanvas = null;
|
||||
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();
|
||||
|
||||
this._setupCameraInput();
|
||||
@@ -207,6 +214,7 @@ export class MapView {
|
||||
const renderer = createGLMapRenderer(canvas);
|
||||
if (!renderer) {
|
||||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||
this.rendererFallback = "webgl-unavailable";
|
||||
return;
|
||||
}
|
||||
// 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()) {
|
||||
renderer.dispose();
|
||||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||
this.rendererFallback = "software";
|
||||
return;
|
||||
}
|
||||
this.glRenderer = renderer;
|
||||
@@ -221,6 +230,19 @@ export class MapView {
|
||||
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
|
||||
// to call every frame: the renderer skips untouched camera/content state.
|
||||
_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).
|
||||
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];
|
||||
// Unclaimed land on the political map, a flat muted olive. Painting it here
|
||||
// lets the political/economic modes skip the textured terrain mesh entirely.
|
||||
@@ -317,6 +331,15 @@ void main() {
|
||||
// spreads over about twenty-four.
|
||||
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
|
||||
// created. The availability check is done without touching canvas.getContext in
|
||||
// environments (jsdom) that would throw and log.
|
||||
@@ -392,20 +415,10 @@ export class GLMapRenderer {
|
||||
}
|
||||
|
||||
_initContext() {
|
||||
const attributes = {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: false,
|
||||
powerPreference: "high-performance",
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
};
|
||||
const gl =
|
||||
this.canvas.getContext("webgl2", attributes) ||
|
||||
this.canvas.getContext("webgl", attributes) ||
|
||||
this.canvas.getContext("experimental-webgl", attributes);
|
||||
this.canvas.getContext("webgl2", CONTEXT_ATTRIBUTES) ||
|
||||
this.canvas.getContext("webgl", CONTEXT_ATTRIBUTES) ||
|
||||
this.canvas.getContext("experimental-webgl", CONTEXT_ATTRIBUTES);
|
||||
if (!gl) throw new Error("no WebGL context");
|
||||
this.gl = gl;
|
||||
this.isWebGL2 = typeof window !== "undefined" &&
|
||||
|
||||
@@ -11,4 +11,5 @@ export { NationModal } from "./modals/nation.js";
|
||||
export { ConfirmModal } from "./modals/confirm.js";
|
||||
export { NewsModal } from "./modals/news.js";
|
||||
export { MapModesModal } from "./modals/map_modes.js";
|
||||
export { CapabilityModal } from "./modals/capabilities.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