feat: backporting existing raylib-cs examples and new official raylib examples to WASM, adopting raylib's original code style
This commit is contained in:
parent
6aa91e3097
commit
f804ab7773
234 changed files with 38997 additions and 10578 deletions
132
Examples/Web/Host.cs
Normal file
132
Examples/Web/Host.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices.JavaScript;
|
||||
|
||||
namespace Examples.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Browser host: owns the single raylib window and the "current" example. The frame loop is
|
||||
/// driven from JavaScript (main.js, via requestAnimationFrame) since a blocking C# loop would
|
||||
/// freeze the page. Methods marked [JSExport] are called from main.js.
|
||||
///
|
||||
/// <para>
|
||||
/// Every switch applies <see cref="IExample.TargetFps"/> and resets the cursor to visible.
|
||||
/// Cursor hiding, <see cref="IExample.ConfigFlags"/>, and per-example window size are not
|
||||
/// honored in the browser; the canvas stays 800x450 and the pointer stays visible.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class Host
|
||||
{
|
||||
public const int screenWidth = 800;
|
||||
public const int screenHeight = 450;
|
||||
|
||||
private static readonly List<IExample> _examples = new(ExampleRegistry.BrowserExamples);
|
||||
|
||||
private static IExample _current;
|
||||
|
||||
public static void Main()
|
||||
{
|
||||
InitWindow(screenWidth, screenHeight, "raylib-cs web examples");
|
||||
SetTargetFPS(60);
|
||||
|
||||
// main.js selects the first example, so its init failures surface through SetExample.
|
||||
}
|
||||
|
||||
/// <summary>Render one frame of the current example; false if it threw and was torn down.</summary>
|
||||
[JSExport]
|
||||
public static bool UpdateFrame()
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_current.Update();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let one misbehaving example kill the whole page; log and stop driving it.
|
||||
Console.WriteLine($"[Examples.Web] '{_current.Name}' threw during Update: {ex}");
|
||||
TryUnload(_current);
|
||||
_current = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Switch the active example by name; false on unknown name or failed Init.</summary>
|
||||
[JSExport]
|
||||
public static bool SetExample(string name)
|
||||
{
|
||||
var next = _examples.Find(e => e.Name == name);
|
||||
if (next == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_current != null)
|
||||
{
|
||||
TryUnload(_current);
|
||||
_current = null;
|
||||
}
|
||||
|
||||
// Reset the baseline; an example may hide/lock the cursor from its own Update(), and
|
||||
// CursorDisabled/CursorHidden are deliberately never applied in the browser.
|
||||
EnableCursor();
|
||||
ShowCursor();
|
||||
SetTargetFPS(next.TargetFps);
|
||||
|
||||
try
|
||||
{
|
||||
next.Init();
|
||||
_current = next;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Examples.Web] '{next.Name}' failed to initialize: {ex}");
|
||||
TryUnload(next);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Target FPS of the current example; main.js paces the frame loop with it.</summary>
|
||||
[JSExport]
|
||||
public static int GetCurrentTargetFps()
|
||||
{
|
||||
return _current?.TargetFps ?? 60;
|
||||
}
|
||||
|
||||
/// <summary>Newline-separated example names, used to populate the nav dropdown.</summary>
|
||||
[JSExport]
|
||||
public static string GetExampleNames()
|
||||
{
|
||||
return string.Join("\n", _examples.ConvertAll(e => e.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map browser CSS mouse coordinates to the fixed 800x450 framebuffer when the canvas
|
||||
/// is CSS-scaled (integer/fit/native display modes in main.js).
|
||||
/// </summary>
|
||||
[JSExport]
|
||||
public static void SetMouseScaleFromDisplay(float scaleX, float scaleY)
|
||||
{
|
||||
SetMouseOffset(0, 0);
|
||||
SetMouseScale(scaleX, scaleY);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort Unload that never lets a failure cascade.</summary>
|
||||
private static void TryUnload(IExample example)
|
||||
{
|
||||
try
|
||||
{
|
||||
example.Unload();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Examples.Web] '{example.Name}' threw during Unload: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
91
Examples/Web/README.md
Normal file
91
Examples/Web/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Examples/Web — raylib-cs in the browser (WebAssembly)
|
||||
|
||||
Runs the raylib examples in the browser via WebAssembly, with a dropdown to switch between them.
|
||||
It proves the `Raylib-cs` NuGet package's `browser-wasm` support end-to-end: the package's
|
||||
`buildTransitive` targets link the shipped `raylib.a` into the .NET wasm runtime.
|
||||
|
||||
The browser-wasm configuration only activates when publishing with
|
||||
`RuntimeIdentifier=browser-wasm`; normal solution builds don't need the `wasm-tools` workload.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK
|
||||
- `dotnet workload install wasm-tools`
|
||||
- The `Raylib-cs` package matching `$(RaylibCsVersion)` (see `Directory.Build.props`) — from
|
||||
nuget.org, or built locally into the repo's `./nuget` feed (`dotnet pack Raylib-cs -c Release --output nuget`).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
dotnet publish Examples -f net10.0 -r browser-wasm -c Release
|
||||
# -> Examples/bin/Release/net10.0/browser-wasm/AppBundle/
|
||||
```
|
||||
|
||||
### Toolchain caveat
|
||||
|
||||
If the link step fails with `wasm-opt: Unknown option '--enable-bulk-memory-opt'`, the installed
|
||||
`wasm-tools` workload is out of sync with the SDK (stale workload band or a system EMSDK on
|
||||
PATH); fix with `dotnet workload update`. As a safety net, `Examples.csproj` defaults
|
||||
browser-wasm publishes to unoptimized native linking; an optimized publish can override those
|
||||
properties.
|
||||
|
||||
## Run
|
||||
|
||||
WebAssembly must be served over HTTP (not `file://`):
|
||||
|
||||
```bash
|
||||
dotnet serve -d Examples/bin/Release/net10.0/browser-wasm/AppBundle # dotnet tool install -g dotnet-serve
|
||||
# or: npx http-server Examples/bin/Release/net10.0/browser-wasm/AppBundle
|
||||
```
|
||||
|
||||
Open the printed URL and use the **Example** dropdown to switch examples.
|
||||
|
||||
## Canvas scaling modes
|
||||
|
||||
The render buffer stays fixed at `800x450`; display scaling is CSS-only. Pick with the **Scale**
|
||||
dropdown (or `?scale=` query param):
|
||||
|
||||
- `native` (default): exact `800x450` CSS pixels.
|
||||
- `integer`: largest whole-number multiple that fits, centered with letterboxing (pixel-perfect).
|
||||
- `fit`: fills the viewport preserving aspect ratio (can be fractional, less crisp).
|
||||
|
||||
Scaling is computed in device pixels and converted back to CSS via `devicePixelRatio`, so the
|
||||
modes behave consistently across OS scale and browser zoom. The status bar shows the live numbers
|
||||
(`DPR`, CSS size, backing size, scale): `integer` should stay crisp at any OS scale or zoom, and
|
||||
`native` should always report `Scale 1.00`.
|
||||
|
||||
## How it works
|
||||
|
||||
A browser can't run raylib's blocking `while (!WindowShouldClose())` loop (it would freeze the
|
||||
page), so frames are driven from JavaScript:
|
||||
|
||||
- `Host.Main()` calls `InitWindow` once; `main.js` then populates the dropdown and selects the
|
||||
first example via `Host.SetExample`, so init failures surface in the on-page error banner.
|
||||
- `main.js` binds the page `<canvas>` to the runtime and calls `Host.UpdateFrame()` from a
|
||||
`requestAnimationFrame` loop paced to the current example's `TargetFps` (raylib's own limiter
|
||||
busy-waits and would peg the main thread). The `Host` methods it calls are `[JSExport]`.
|
||||
- On switch, `Host.SetExample` unloads the previous example, resets the cursor to visible, and
|
||||
applies the next `TargetFps`. Cursor hiding and `ConfigFlags` are not honored in the browser.
|
||||
If `Init` or `Update` throws, the example is unloaded and an error banner is shown.
|
||||
- Each example is a single `.cs` file implementing `IExample` (`Init` / `Update` / `Unload`); the
|
||||
host owns the window, so examples never call `InitWindow`/`CloseWindow`. Platform differences
|
||||
(e.g. GLSL 100 vs 330) are handled inline with `#if BROWSER` guards, not separate files.
|
||||
|
||||
## Adding more examples
|
||||
|
||||
Examples are **auto-discovered by reflection** (`ExampleRegistry.DiscoverAll`) — no list to edit.
|
||||
Drop a new `.cs` file in the matching category folder implementing `IExample`, splitting the
|
||||
original monolithic `Main` as:
|
||||
|
||||
```
|
||||
Main() { <setup>; while(!WindowShouldClose()){ <body> } <cleanup> }
|
||||
-> Init() { <setup, minus InitWindow/SetTargetFPS> } // loop-spanning locals become fields
|
||||
Update(){ <body> } // keep BeginDrawing..EndDrawing
|
||||
Unload(){ <cleanup, minus CloseWindow> }
|
||||
```
|
||||
|
||||
Keep the standalone `static Main()` as a thin driver so the example still runs on its own.
|
||||
|
||||
If an example can't run on single-threaded wasm, add its type to `DesktopExcludedFromBrowser` in
|
||||
`ExampleRegistry.cs`; `BrowserOnly` is the inverse list. Assets under `resources/` are bundled
|
||||
into the wasm virtual filesystem automatically.
|
||||
79
Examples/Web/index.html
Normal file
79
Examples/Web/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>raylib-cs — browser examples</title>
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico"/>
|
||||
<link rel="modulepreload" href="./main.js"/>
|
||||
<link rel="modulepreload" href="./_framework/dotnet.js"/>
|
||||
<style>
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
background: #1d1d1d;
|
||||
color: #eaeaea;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
h1 { font-size: 1.1rem; font-weight: 600; }
|
||||
#toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; }
|
||||
#examples { font-size: 1rem; padding: .3rem .5rem; }
|
||||
#scaleMode { font-size: 0.95rem; padding: .2rem .4rem; }
|
||||
#viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
background: #111;
|
||||
}
|
||||
canvas {
|
||||
border: 1px solid #444;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 800px;
|
||||
height: 450px;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
}
|
||||
#status { color: #888; font-size: .85rem; margin: 0; }
|
||||
#error {
|
||||
color: #ff6b6b;
|
||||
background: #3a1d1d;
|
||||
border: 1px solid #6b2b2b;
|
||||
border-radius: 4px;
|
||||
padding: .4rem .6rem;
|
||||
font-size: .9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>raylib-cs running in the browser (WebAssembly)</h1>
|
||||
<div id="toolbar">
|
||||
<label for="examples">Example: </label>
|
||||
<select id="examples"><option>loading…</option></select>
|
||||
<label for="scaleMode"> Scale: </label>
|
||||
<select id="scaleMode">
|
||||
<option value="integer">Integer</option>
|
||||
<option value="native">Native (1x)</option>
|
||||
<option value="fit">Fit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="error" hidden>This example crashed — see the browser console for details. Select another example to continue.</div>
|
||||
<div id="viewport">
|
||||
<canvas id="canvas" width="800" height="450" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
|
||||
</div>
|
||||
<div id="status">Initializing .NET + raylib…</div>
|
||||
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
176
Examples/Web/main.js
Normal file
176
Examples/Web/main.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { dotnet } from './_framework/dotnet.js'
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
normalizeScaleMode,
|
||||
computeDisplaySize
|
||||
} from './scaleUtils.js'
|
||||
|
||||
const status = document.getElementById('status');
|
||||
const errorBanner = document.getElementById('error');
|
||||
const select = document.getElementById('examples');
|
||||
const canvas = document.getElementById('canvas');
|
||||
const viewport = document.getElementById('viewport');
|
||||
const scaleModeSelect = document.getElementById('scaleMode');
|
||||
|
||||
function readScaleModeFromQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return normalizeScaleMode(params.get('scale'));
|
||||
}
|
||||
|
||||
function writeScaleModeToQuery(mode) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('scale', mode);
|
||||
const query = params.toString();
|
||||
const nextUrl = query.length > 0 ? `${window.location.pathname}?${query}` : window.location.pathname;
|
||||
window.history.replaceState(null, '', nextUrl);
|
||||
}
|
||||
|
||||
let scaleMode = readScaleModeFromQuery();
|
||||
let Host = null;
|
||||
|
||||
function ensureBackingBufferSize() {
|
||||
if (canvas.width !== CANVAS_WIDTH) {
|
||||
canvas.width = CANVAS_WIDTH;
|
||||
}
|
||||
|
||||
if (canvas.height !== CANVAS_HEIGHT) {
|
||||
canvas.height = CANVAS_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
function updateScaleDiagnostics(displayScale, cssWidth, cssHeight, deviceWidth, deviceHeight, dpr) {
|
||||
status.textContent =
|
||||
`Running. Example: ${scaleMode} scaling | DPR ${dpr.toFixed(2)} | ` +
|
||||
`CSS ${cssWidth.toFixed(2)}x${cssHeight.toFixed(2)} | ` +
|
||||
`Device ${deviceWidth}x${deviceHeight} | Backing ${canvas.width}x${canvas.height} | ` +
|
||||
`Scale ${displayScale.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function syncMouseScale() {
|
||||
if (!Host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssWidth = canvas.clientWidth;
|
||||
const cssHeight = canvas.clientHeight;
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CSS/display mouse coords to the fixed backing buffer (canvas.width/height).
|
||||
const scaleX = canvas.width / cssWidth;
|
||||
const scaleY = canvas.height / cssHeight;
|
||||
Host.SetMouseScaleFromDisplay(scaleX, scaleY);
|
||||
}
|
||||
|
||||
function applyDisplayScale() {
|
||||
ensureBackingBufferSize();
|
||||
|
||||
const viewportWidth = viewport.clientWidth || CANVAS_WIDTH;
|
||||
const viewportHeight = viewport.clientHeight || CANVAS_HEIGHT;
|
||||
const dprRaw = window.devicePixelRatio ?? 1;
|
||||
const display = computeDisplaySize(scaleMode, viewportWidth, viewportHeight, dprRaw);
|
||||
|
||||
canvas.style.width = `${display.cssWidth}px`;
|
||||
canvas.style.height = `${display.cssHeight}px`;
|
||||
|
||||
viewport.style.justifyContent = 'center';
|
||||
viewport.style.alignItems = 'center';
|
||||
|
||||
syncMouseScale();
|
||||
|
||||
updateScaleDiagnostics(
|
||||
display.scale,
|
||||
display.cssWidth,
|
||||
display.cssHeight,
|
||||
display.deviceWidth,
|
||||
display.deviceHeight,
|
||||
display.dpr
|
||||
);
|
||||
}
|
||||
|
||||
function setScaleMode(mode, updateQuery) {
|
||||
const nextMode = normalizeScaleMode(mode);
|
||||
if (nextMode === scaleMode && !updateQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
scaleMode = nextMode;
|
||||
if (scaleModeSelect) {
|
||||
scaleModeSelect.value = scaleMode;
|
||||
}
|
||||
|
||||
if (updateQuery) {
|
||||
writeScaleModeToQuery(scaleMode);
|
||||
}
|
||||
|
||||
applyDisplayScale();
|
||||
}
|
||||
|
||||
if (scaleModeSelect) {
|
||||
scaleModeSelect.value = scaleMode;
|
||||
scaleModeSelect.addEventListener('change', () => {
|
||||
setScaleMode(scaleModeSelect.value, true);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', applyDisplayScale);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', applyDisplayScale);
|
||||
}
|
||||
|
||||
applyDisplayScale();
|
||||
|
||||
const { getAssemblyExports, getConfig, runMain } = await dotnet
|
||||
.withDiagnosticTracing(false)
|
||||
.create();
|
||||
|
||||
const config = getConfig();
|
||||
const exports = await getAssemblyExports(config.mainAssemblyName);
|
||||
Host = exports.Examples.Web.Host;
|
||||
|
||||
// raylib's GLFW/emscripten backend renders into this canvas.
|
||||
dotnet.instance.Module['canvas'] = canvas;
|
||||
|
||||
// Runs Host.Main() -> InitWindow. Must come after the canvas is bound.
|
||||
await runMain();
|
||||
applyDisplayScale();
|
||||
|
||||
let targetFps = 60;
|
||||
function selectExample(name) {
|
||||
const ok = Host.SetExample(name);
|
||||
targetFps = Host.GetCurrentTargetFps();
|
||||
errorBanner.hidden = ok;
|
||||
}
|
||||
|
||||
select.innerHTML = '';
|
||||
for (const name of Host.GetExampleNames().split('\n').filter(n => n.length > 0)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.addEventListener('change', () => selectExample(select.value));
|
||||
selectExample(select.value);
|
||||
|
||||
setScaleMode(scaleMode, false);
|
||||
|
||||
// Drive raylib one frame per animation tick, paced to the example's target FPS here rather
|
||||
// than by raylib's limiter (which busy-waits in EndDrawing and would peg the main thread).
|
||||
let nextFrameTime = 0;
|
||||
function mainLoop(timestamp) {
|
||||
requestAnimationFrame(mainLoop);
|
||||
|
||||
if (timestamp < nextFrameTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule one interval ahead; resync to now if we've fallen behind (hidden tab).
|
||||
nextFrameTime = Math.max(nextFrameTime + 1000 / targetFps, timestamp);
|
||||
|
||||
if (!Host.UpdateFrame()) {
|
||||
errorBanner.hidden = false;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(mainLoop);
|
||||
67
Examples/Web/scaleUtils.js
Normal file
67
Examples/Web/scaleUtils.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export const CANVAS_WIDTH = 800;
|
||||
export const CANVAS_HEIGHT = 450;
|
||||
export const SCALE_MODES = new Set(['integer', 'native', 'fit']);
|
||||
export const DEFAULT_SCALE_MODE = 'native';
|
||||
|
||||
export function normalizeScaleMode(mode) {
|
||||
if (!mode) {
|
||||
return DEFAULT_SCALE_MODE;
|
||||
}
|
||||
|
||||
const normalized = String(mode).toLowerCase();
|
||||
return SCALE_MODES.has(normalized) ? normalized : DEFAULT_SCALE_MODE;
|
||||
}
|
||||
|
||||
export function computeDisplaySize(mode, viewportWidth, viewportHeight, dpr = 1) {
|
||||
const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
|
||||
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) {
|
||||
return {
|
||||
cssWidth: CANVAS_WIDTH / safeDpr,
|
||||
cssHeight: CANVAS_HEIGHT / safeDpr,
|
||||
deviceWidth: CANVAS_WIDTH,
|
||||
deviceHeight: CANVAS_HEIGHT,
|
||||
scale: 1,
|
||||
dpr: safeDpr
|
||||
};
|
||||
}
|
||||
|
||||
// Convert viewport from CSS pixels to device pixels first so scaling is DPI/zoom aware.
|
||||
const viewportDeviceWidth = Math.max(1, Math.floor(viewportWidth * safeDpr));
|
||||
const viewportDeviceHeight = Math.max(1, Math.floor(viewportHeight * safeDpr));
|
||||
|
||||
let deviceWidth;
|
||||
let deviceHeight;
|
||||
let scale;
|
||||
|
||||
if (mode === 'native') {
|
||||
// 1 source pixel -> 1 physical device pixel.
|
||||
scale = 1;
|
||||
deviceWidth = CANVAS_WIDTH;
|
||||
deviceHeight = CANVAS_HEIGHT;
|
||||
} else if (mode === 'fit') {
|
||||
const fitRatio = Math.min(viewportDeviceWidth / CANVAS_WIDTH, viewportDeviceHeight / CANVAS_HEIGHT);
|
||||
const safeRatio = Number.isFinite(fitRatio) && fitRatio > 0 ? fitRatio : 1;
|
||||
scale = safeRatio;
|
||||
deviceWidth = Math.max(1, Math.floor(CANVAS_WIDTH * safeRatio));
|
||||
deviceHeight = Math.max(1, Math.floor(CANVAS_HEIGHT * safeRatio));
|
||||
} else {
|
||||
// integer mode
|
||||
const integerScale = Math.max(1, Math.floor(Math.min(
|
||||
viewportDeviceWidth / CANVAS_WIDTH,
|
||||
viewportDeviceHeight / CANVAS_HEIGHT
|
||||
)));
|
||||
scale = integerScale;
|
||||
deviceWidth = CANVAS_WIDTH * integerScale;
|
||||
deviceHeight = CANVAS_HEIGHT * integerScale;
|
||||
}
|
||||
|
||||
return {
|
||||
cssWidth: deviceWidth / safeDpr,
|
||||
cssHeight: deviceHeight / safeDpr,
|
||||
deviceWidth,
|
||||
deviceHeight,
|
||||
scale,
|
||||
dpr: safeDpr
|
||||
};
|
||||
}
|
||||
92
Examples/Web/scaleUtils.test.js
Normal file
92
Examples/Web/scaleUtils.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
normalizeScaleMode,
|
||||
computeDisplaySize
|
||||
} from './scaleUtils.js';
|
||||
|
||||
describe('normalizeScaleMode', () => {
|
||||
it('returns native for null, undefined, and empty string', () => {
|
||||
assert.equal(normalizeScaleMode(null), 'native');
|
||||
assert.equal(normalizeScaleMode(undefined), 'native');
|
||||
assert.equal(normalizeScaleMode(''), 'native');
|
||||
});
|
||||
|
||||
it('normalizes allowlisted values case-insensitively', () => {
|
||||
assert.equal(normalizeScaleMode('INTEGER'), 'integer');
|
||||
assert.equal(normalizeScaleMode('Fit'), 'fit');
|
||||
assert.equal(normalizeScaleMode('native'), 'native');
|
||||
});
|
||||
|
||||
it('returns native for unknown values', () => {
|
||||
assert.equal(normalizeScaleMode('stretch'), 'native');
|
||||
assert.equal(normalizeScaleMode('bogus'), 'native');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDisplaySize', () => {
|
||||
it('native mode uses 1:1 device pixels at scale 1', () => {
|
||||
const result = computeDisplaySize('native', 1200, 800, 1);
|
||||
|
||||
assert.equal(result.scale, 1);
|
||||
assert.equal(result.deviceWidth, CANVAS_WIDTH);
|
||||
assert.equal(result.deviceHeight, CANVAS_HEIGHT);
|
||||
assert.equal(result.cssWidth, CANVAS_WIDTH);
|
||||
assert.equal(result.cssHeight, CANVAS_HEIGHT);
|
||||
assert.equal(result.dpr, 1);
|
||||
});
|
||||
|
||||
it('integer mode scales by floored ratio with minimum scale 1', () => {
|
||||
const doubled = computeDisplaySize('integer', 1600, 900, 1);
|
||||
|
||||
assert.equal(doubled.scale, 2);
|
||||
assert.equal(doubled.deviceWidth, CANVAS_WIDTH * 2);
|
||||
assert.equal(doubled.deviceHeight, CANVAS_HEIGHT * 2);
|
||||
|
||||
const single = computeDisplaySize('integer', 900, 500, 1);
|
||||
|
||||
assert.equal(single.scale, 1);
|
||||
assert.equal(single.deviceWidth, CANVAS_WIDTH);
|
||||
assert.equal(single.deviceHeight, CANVAS_HEIGHT);
|
||||
});
|
||||
|
||||
it('fit mode scales proportionally and keeps device dimensions at least 1', () => {
|
||||
const result = computeDisplaySize('fit', 400, 225, 1);
|
||||
|
||||
assert.equal(result.scale, 0.5);
|
||||
assert.equal(result.deviceWidth, 400);
|
||||
assert.equal(result.deviceHeight, 225);
|
||||
});
|
||||
|
||||
it('fit mode falls back to scale 1 for zero or negative viewport', () => {
|
||||
const zero = computeDisplaySize('fit', 0, 0, 1);
|
||||
|
||||
assert.equal(zero.scale, 1);
|
||||
assert.equal(zero.deviceWidth, CANVAS_WIDTH);
|
||||
assert.equal(zero.deviceHeight, CANVAS_HEIGHT);
|
||||
|
||||
const negative = computeDisplaySize('fit', -100, -50, 1);
|
||||
|
||||
assert.equal(negative.scale, 1);
|
||||
assert.equal(negative.deviceWidth, CANVAS_WIDTH);
|
||||
assert.equal(negative.deviceHeight, CANVAS_HEIGHT);
|
||||
});
|
||||
|
||||
it('applies dpr to viewport before scaling and converts css size back', () => {
|
||||
const result = computeDisplaySize('native', 400, 225, 2);
|
||||
|
||||
assert.equal(result.dpr, 2);
|
||||
assert.equal(result.deviceWidth, CANVAS_WIDTH);
|
||||
assert.equal(result.deviceHeight, CANVAS_HEIGHT);
|
||||
assert.equal(result.cssWidth, CANVAS_WIDTH / 2);
|
||||
assert.equal(result.cssHeight, CANVAS_HEIGHT / 2);
|
||||
});
|
||||
|
||||
it('treats invalid dpr as 1', () => {
|
||||
assert.equal(computeDisplaySize('native', 800, 450, 0).dpr, 1);
|
||||
assert.equal(computeDisplaySize('native', 800, 450, NaN).dpr, 1);
|
||||
assert.equal(computeDisplaySize('native', 800, 450, Infinity).dpr, 1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue