Skip to content

Commit 9c36935

Browse files
committed
feat(platform/wasm): use local font access api
1 parent fce4c40 commit 9c36935

6 files changed

Lines changed: 341 additions & 22 deletions

File tree

1.82 MB
Binary file not shown.

.github/assets/arial.ttf

359 KB
Binary file not shown.

.github/assets/font.zip

-2.22 MB
Binary file not shown.

.github/assets/index.html

Lines changed: 323 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,38 @@
5757
opacity: 0.85;
5858
margin: 0;
5959
}
60+
#font-permission-btn {
61+
display: none;
62+
margin: 18px auto 0;
63+
padding: 12px 26px;
64+
border-radius: 999px;
65+
border: 1px solid rgba(126, 188, 255, 0.55);
66+
background: linear-gradient(135deg, rgba(28, 48, 92, 0.9), rgba(16, 24, 48, 0.9));
67+
color: #d7e5ff;
68+
font-size: 0.95rem;
69+
letter-spacing: 0.08em;
70+
text-transform: uppercase;
71+
font-weight: 600;
72+
cursor: pointer;
73+
box-shadow: 0 10px 24px rgba(10, 22, 48, 0.4);
74+
transition: transform 0.18s ease, opacity 0.18s ease, border-color 0.18s ease;
75+
}
76+
#font-permission-btn.visible {
77+
display: inline-flex;
78+
align-items: center;
79+
justify-content: center;
80+
gap: 10px;
81+
}
82+
#font-permission-btn:hover:not(:disabled),
83+
#font-permission-btn:focus-visible:not(:disabled) {
84+
transform: translateY(-1px);
85+
border-color: rgba(156, 208, 255, 0.85);
86+
outline: none;
87+
}
88+
#font-permission-btn:disabled {
89+
cursor: wait;
90+
opacity: 0.6;
91+
}
6092
#metrics {
6193
position: fixed;
6294
bottom: 24px;
@@ -194,6 +226,7 @@
194226
<h1 id="overlay-title"></h1>
195227
<div id="overlay-intro"></div>
196228
<p id="overlay-status"></p>
229+
<button id="font-permission-btn" type="button"></button>
197230
</div>
198231
</div>
199232
<canvas id="canvas" oncontextmenu="event.preventDefault()"></canvas>
@@ -223,6 +256,7 @@ <h1 id="overlay-title"></h1>
223256
const titleEl = document.getElementById('overlay-title');
224257
const introEl = document.getElementById('overlay-intro');
225258
const statusEl = document.getElementById('overlay-status');
259+
const fontPermissionBtn = document.getElementById('font-permission-btn');
226260
const sourceLinkEl = document.getElementById('source-link');
227261
const uvLabel = document.getElementById('uv_label');
228262
const pvLabel = document.getElementById('pv_label');
@@ -280,6 +314,12 @@ <h1 id="overlay-title"></h1>
280314
loadingResources: '正在加载游戏资源…',
281315
loadingMainArchive: '正在加载核心资源包…',
282316
loadingFontArchive: '正在加载字体资源包…',
317+
waitingLocalFontAuthorization: '请授权读取本地字体…',
318+
requestingLocalFont: '正在请求访问本地字体…',
319+
loadingEnglishFont: '正在加载英文字体…',
320+
loadingChineseFont: '正在加载中文字体…',
321+
localFontUnavailable: '无法使用本地字体,改为下载字体资源…',
322+
localFontButton: '授权使用本地字体',
283323
runtimeReady: '引擎已就绪,正在启动…',
284324
runtimeFailedTitle: '运行时加载失败',
285325
runtimeFailedDetail: '加载运行时失败,请稍后重试。'
@@ -296,11 +336,213 @@ <h1 id="overlay-title"></h1>
296336
loadingResources: 'Loading game assets…',
297337
loadingMainArchive: 'Fetching core content…',
298338
loadingFontArchive: 'Fetching font resources…',
339+
waitingLocalFontAuthorization: 'Awaiting font authorization…',
340+
requestingLocalFont: 'Requesting access to local fonts…',
341+
loadingEnglishFont: 'Loading English font…',
342+
loadingChineseFont: 'Loading Chinese font…',
343+
localFontUnavailable: 'Local fonts unavailable. Fetching bundled font…',
344+
localFontButton: 'Authorize local fonts',
299345
runtimeReady: 'Runtime ready. Launching…',
300346
runtimeFailedTitle: 'Runtime failed to load',
301347
runtimeFailedDetail: 'We could not load the runtime. Please try again later.'
302348
};
303349

350+
const supportsLocalFontAPI = typeof window.queryLocalFonts === 'function';
351+
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
352+
const ENGLISH_FONT_POSTSCRIPT_NAMES = [
353+
'ArialMT',
354+
'LiberationSerif',
355+
'Arial',
356+
'Helvetica'
357+
];
358+
const CHINESE_FONT_POSTSCRIPT_NAMES = [
359+
'STHeiti',
360+
'MicrosoftYaHei',
361+
'HeitiSC',
362+
];
363+
364+
const CRC32_TABLE = (() => {
365+
const table = new Uint32Array(256);
366+
for (let n = 0; n < 256; n++) {
367+
let c = n;
368+
for (let k = 0; k < 8; k++) {
369+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
370+
}
371+
table[n] = c >>> 0;
372+
}
373+
return table;
374+
})();
375+
376+
function calculateCRC32(data) {
377+
let crc = 0xFFFFFFFF;
378+
for (let i = 0; i < data.length; i++) {
379+
crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);
380+
}
381+
return (crc ^ 0xFFFFFFFF) >>> 0;
382+
}
383+
384+
function sanitizeFontName(name, index) {
385+
const base = name.trim();
386+
return base.replace(/[^A-Za-z0-9-_.]/g, '_');
387+
}
388+
389+
function createFontZip(entries) {
390+
if (!entries.length) {
391+
throw new Error('No font data provided to zip');
392+
}
393+
if (!textEncoder) {
394+
throw new Error('TextEncoder is not available in this environment');
395+
}
396+
397+
const files = entries.map((entry, idx) => {
398+
const safeName = sanitizeFontName(entry.name, idx);
399+
const fileName = safeName.endsWith('.ttf') ? safeName : safeName + '.ttf';
400+
const fullName = 'asset/font/' + fileName;
401+
console.log('Adding font to zip:', fullName);
402+
const nameBytes = textEncoder.encode(fullName);
403+
const data = entry.data;
404+
const crc32 = calculateCRC32(data);
405+
const nameLength = nameBytes.length;
406+
const size = data.length;
407+
return {
408+
data,
409+
crc32,
410+
nameBytes,
411+
nameLength,
412+
size
413+
};
414+
});
415+
416+
let localSize = 0;
417+
let centralSize = 0;
418+
files.forEach(file => {
419+
localSize += 30 + file.nameLength + file.size;
420+
centralSize += 46 + file.nameLength;
421+
});
422+
423+
const endOfCentralDirectorySize = 22;
424+
const totalSize = localSize + centralSize + endOfCentralDirectorySize;
425+
const output = new Uint8Array(totalSize);
426+
const view = new DataView(output.buffer);
427+
428+
let offset = 0;
429+
const centralRecords = [];
430+
431+
files.forEach(file => {
432+
const localHeaderOffset = offset;
433+
view.setUint32(offset, 0x04034b50, true);
434+
view.setUint16(offset + 4, 20, true); // version needed to extract
435+
view.setUint16(offset + 6, 0, true); // general purpose bit flag
436+
view.setUint16(offset + 8, 0, true); // compression (store)
437+
view.setUint16(offset + 10, 0, true); // last mod file time
438+
view.setUint16(offset + 12, 0, true); // last mod file date
439+
view.setUint32(offset + 14, file.crc32, true);
440+
view.setUint32(offset + 18, file.size, true); // compressed size
441+
view.setUint32(offset + 22, file.size, true); // uncompressed size
442+
view.setUint16(offset + 26, file.nameLength, true);
443+
view.setUint16(offset + 28, 0, true); // extra field length
444+
offset += 30;
445+
446+
output.set(file.nameBytes, offset);
447+
offset += file.nameLength;
448+
449+
output.set(file.data, offset);
450+
offset += file.size;
451+
452+
centralRecords.push({
453+
file,
454+
localHeaderOffset
455+
});
456+
});
457+
458+
const centralDirectoryOffset = offset;
459+
460+
centralRecords.forEach(({ file, localHeaderOffset }) => {
461+
view.setUint32(offset, 0x02014b50, true);
462+
view.setUint16(offset + 4, 20, true); // version made by
463+
view.setUint16(offset + 6, 20, true); // version needed to extract
464+
view.setUint16(offset + 8, 0, true); // general purpose bit flag
465+
view.setUint16(offset + 10, 0, true); // compression
466+
view.setUint16(offset + 12, 0, true); // last mod file time
467+
view.setUint16(offset + 14, 0, true); // last mod file date
468+
view.setUint32(offset + 16, file.crc32, true);
469+
view.setUint32(offset + 20, file.size, true); // compressed size
470+
view.setUint32(offset + 24, file.size, true); // uncompressed size
471+
view.setUint16(offset + 28, file.nameLength, true);
472+
view.setUint16(offset + 30, 0, true); // extra field length
473+
view.setUint16(offset + 32, 0, true); // file comment length
474+
view.setUint16(offset + 34, 0, true); // disk number start
475+
view.setUint16(offset + 36, 0, true); // internal file attributes
476+
view.setUint32(offset + 38, 0, true); // external file attributes
477+
view.setUint32(offset + 42, localHeaderOffset, true); // relative offset
478+
offset += 46;
479+
480+
output.set(file.nameBytes, offset);
481+
offset += file.nameLength;
482+
});
483+
484+
const centralDirectorySize = offset - centralDirectoryOffset;
485+
486+
view.setUint32(offset, 0x06054b50, true);
487+
view.setUint16(offset + 4, 0, true); // number of this disk
488+
view.setUint16(offset + 6, 0, true); // number of the disk with the start
489+
view.setUint16(offset + 8, files.length, true); // total entries on this disk
490+
view.setUint16(offset + 10, files.length, true); // total entries
491+
view.setUint32(offset + 12, centralDirectorySize, true);
492+
view.setUint32(offset + 16, centralDirectoryOffset, true);
493+
view.setUint16(offset + 20, 0, true); // comment length
494+
495+
return output;
496+
}
497+
498+
async function fetchFontEntry(url, fallbackName) {
499+
const response = await fetch(url);
500+
if (!response.ok) {
501+
throw new Error('HTTP ' + response.status + ' while fetching ' + url);
502+
}
503+
const buffer = await response.arrayBuffer();
504+
return {
505+
name: fallbackName,
506+
data: new Uint8Array(buffer)
507+
};
508+
}
509+
510+
async function getLocalFontEntry(postscriptNames) {
511+
if (!supportsLocalFontAPI) {
512+
return null;
513+
}
514+
try {
515+
const fonts = await window.queryLocalFonts({ postscriptNames });
516+
if (!fonts || fonts.length === 0) {
517+
return null;
518+
}
519+
let selected = null;
520+
for (const desiredName of postscriptNames) {
521+
selected = fonts.find(font => font.postscriptName === desiredName);
522+
if (selected) break;
523+
}
524+
if (!selected) {
525+
selected = fonts[0];
526+
}
527+
const blob = await selected.blob();
528+
const arrayBuffer = await blob.arrayBuffer();
529+
const baseName = sanitizeFontName(selected.postscriptName || selected.fullName);
530+
return {
531+
name: baseName,
532+
data: new Uint8Array(arrayBuffer)
533+
};
534+
} catch (err) {
535+
console.error('queryLocalFonts failed', err);
536+
throw err;
537+
}
538+
}
539+
540+
function writeFontZipToModule(entries) {
541+
const zipData = createFontZip(entries);
542+
Module.FS.writeFile('/data/font.zip', zipData, { canOwn: true });
543+
console.log('font.zip prepared:', entries.map(entry => entry.name));
544+
}
545+
304546
titleEl.textContent = strings.gameTitle;
305547
introEl.innerHTML = strings.intro.map(text => '<p>' + text + '</p>').join('');
306548
sourceLinkEl.setAttribute('aria-label', strings.sourceAriaLabel);
@@ -414,26 +656,89 @@ <h1 id="overlay-title"></h1>
414656
Module.removeRunDependency(runDependency);
415657
});
416658
Module.addRunDependency(fontDependency);
417-
setStatus(strings.loadingFontArchive);
418-
fetch('./font.zip')
419-
.then(function (response) {
420-
if (!response.ok) {
421-
throw new Error('HTTP ' + response.status + ' while fetching font.zip');
422-
}
423-
return response.arrayBuffer();
424-
})
425-
.then(function (buffer) {
426-
const data = new Uint8Array(buffer);
427-
Module.FS.writeFile('/data/font.zip', data, { canOwn: true });
428-
console.log('font.zip loaded:', Module.FS.readdir('/data'));
429-
})
430-
.catch(function (err) {
431-
console.error('Failed to load font.zip', err);
432-
throw err;
433-
})
434-
.finally(function () {
659+
660+
let fontDependencyResolved = false;
661+
function resolveFontDependency() {
662+
if (!fontDependencyResolved) {
663+
fontDependencyResolved = true;
435664
Module.removeRunDependency(fontDependency);
665+
}
666+
}
667+
668+
function loadBundledFontZip() {
669+
(async () => {
670+
try {
671+
setStatus(strings.loadingEnglishFont);
672+
const englishEntry = await fetchFontEntry('./arial.ttf', 'arial.ttf');
673+
setStatus(strings.loadingChineseFont);
674+
const chineseEntry = await fetchFontEntry('./SourceHanSansSC-Regular.ttf', 'SourceHanSansSC-Regular.ttf');
675+
writeFontZipToModule([englishEntry, chineseEntry]);
676+
setStatus(strings.loadingFontArchive);
677+
} catch (err) {
678+
console.error('Failed to load fallback font files', err);
679+
throw err;
680+
} finally {
681+
resolveFontDependency();
682+
}
683+
})().catch(err => {
684+
console.error('Unhandled fallback font error', err);
436685
});
686+
}
687+
688+
function handleLocalFontAuthorization() {
689+
if (!supportsLocalFontAPI || !fontPermissionBtn) {
690+
loadBundledFontZip();
691+
return;
692+
}
693+
694+
fontPermissionBtn.textContent = strings.localFontButton;
695+
fontPermissionBtn.setAttribute('aria-label', strings.localFontButton);
696+
fontPermissionBtn.classList.add('visible');
697+
fontPermissionBtn.disabled = false;
698+
setStatus(strings.waitingLocalFontAuthorization);
699+
700+
const requestLocalFonts = async () => {
701+
fontPermissionBtn.disabled = true;
702+
setStatus(strings.requestingLocalFont);
703+
try {
704+
const fontEntries = [];
705+
706+
const englishEntry = await getLocalFontEntry(ENGLISH_FONT_POSTSCRIPT_NAMES);
707+
if (englishEntry) {
708+
fontEntries.push(englishEntry);
709+
} else {
710+
setStatus(strings.loadingEnglishFont);
711+
fontEntries.push(await fetchFontEntry('./arial.ttf', 'arial.ttf'));
712+
}
713+
714+
const chineseEntry = await getLocalFontEntry(CHINESE_FONT_POSTSCRIPT_NAMES);
715+
if (chineseEntry) {
716+
fontEntries.push(chineseEntry);
717+
} else {
718+
setStatus(strings.loadingChineseFont);
719+
fontEntries.push(await fetchFontEntry('./SourceHanSansSC-Regular.ttf', 'SourceHanSansSC-Regular.ttf'));
720+
}
721+
722+
writeFontZipToModule(fontEntries);
723+
setStatus(strings.loadingFontArchive);
724+
fontPermissionBtn.classList.remove('visible');
725+
resolveFontDependency();
726+
} catch (err) {
727+
console.warn('Local font access failed, falling back to bundled font files', err);
728+
fontPermissionBtn.classList.remove('visible');
729+
setStatus(strings.localFontUnavailable);
730+
loadBundledFontZip();
731+
}
732+
};
733+
734+
fontPermissionBtn.addEventListener('click', () => {
735+
requestLocalFonts().catch(err => {
736+
console.error('Unexpected error during local font request', err);
737+
});
738+
}, { once: true });
739+
}
740+
741+
handleLocalFontAuthorization();
437742
}],
438743
onRuntimeInitialized: function () {
439744
console.log('Soluna runtime ready');

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
cp "${{ steps.build.outputs.SOLUNA_JS_PATH }}" ./build/
3434
zip -r ./build/main.zip asset core gameplay localization service visual main.game main.lua
3535
cp .github/assets/index.html ./build/
36-
cp .github/assets/font.zip ./build/
36+
cp .github/assets/*.ttf ./build/
3737
cp .github/assets/coi-serviceworker.min.js ./build/
3838
- name: Upload static files as artifact
3939
id: deployment

0 commit comments

Comments
 (0)