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 ;
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 - Z a - z 0 - 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' ) ;
0 commit comments