diff --git a/common/effect-player.js b/common/effect-player.js index a01f37f..b907ead 100644 --- a/common/effect-player.js +++ b/common/effect-player.js @@ -5,6 +5,7 @@ var scriptCache = {}; var pagInitCache = {}; var videoLayoutCache = {}; + var svgaVideoItemCache = {}; var MIN_SPLIT_MATCH_FRAMES = 3; var MIN_USEFUL_FRAME_BRIGHTNESS = 0.035; var MIN_SPLIT_SATURATION_GAP = 0.08; @@ -41,6 +42,23 @@ return scriptCache[url]; } + function loadSVGAItemOnce(sourceURL, canvas, options) { + if (svgaVideoItemCache[sourceURL]) return svgaVideoItemCache[sourceURL]; + svgaVideoItemCache[sourceURL] = loadScriptOnce(options.svgaScript) + .then(function () { + if (!window.SVGA) throw new Error('svga_unavailable'); + return new Promise(function (resolve, reject) { + var parser = new window.SVGA.Parser(canvas); + parser.load(sourceURL, resolve, reject); + }); + }) + .catch(function (error) { + delete svgaVideoItemCache[sourceURL]; + throw error; + }); + return svgaVideoItemCache[sourceURL]; + } + function waitForWindowLoad() { if (document.readyState === 'complete') return Promise.resolve(); return new Promise(function (resolve) { @@ -49,7 +67,9 @@ } function extensionFromURL(url) { - var clean = String(url || '').split('?')[0].split('#')[0]; + var clean = String(url || '') + .split('?')[0] + .split('#')[0]; var match = clean.match(/\.([a-z0-9]+)$/i); return match ? match[1].toLowerCase() : ''; } @@ -87,10 +107,7 @@ var separator = base.indexOf('?') >= 0 ? '&' : '?'; return base + separator + 'url=' + encodeURIComponent(url); } - if ( - window.HyAppAPI && - typeof window.HyAppAPI.buildURL === 'function' - ) { + if (window.HyAppAPI && typeof window.HyAppAPI.buildURL === 'function') { return window.HyAppAPI.buildURL('/api/v1/media/effects/proxy', { url: url, }); @@ -456,8 +473,7 @@ ) { return; } - var alphaStats = - first.saturation < second.saturation ? first : second; + var alphaStats = first.saturation < second.saturation ? first : second; var saturationGap = Math.abs(second.saturation - first.saturation); if ( saturationGap < MIN_SPLIT_SATURATION_GAP || @@ -505,9 +521,7 @@ var width = Math.max(1, video.videoWidth || 1); var height = Math.max(1, video.videoHeight || 1); return { - kind: isHorizontal - ? 'splitAlphaHorizontal' - : 'splitAlphaVertical', + kind: isHorizontal ? 'splitAlphaHorizontal' : 'splitAlphaVertical', hasAlphaMask: true, hasNativeAlpha: false, contentAspectRatio: isHorizontal @@ -584,14 +598,14 @@ } return ( detectPackedAlphaTopRight(packedFrames) || - sampleVideoFrames(video, 'split', maxWidth).then(function ( - splitFrames - ) { - return ( - detectSplitAlpha(splitFrames, video) || - fullFrameLayout(video, 'normal', false) - ); - }) + sampleVideoFrames(video, 'split', maxWidth).then( + function (splitFrames) { + return ( + detectSplitAlpha(splitFrames, video) || + fullFrameLayout(video, 'normal', false) + ); + } + ) ); }) .then(function (layout) { @@ -603,7 +617,11 @@ function rectToPixels(rect, width, height) { var x = clamp(Math.round(rect.x * width), 0, width - 1); var y = clamp(Math.round(rect.y * height), 0, height - 1); - var right = clamp(Math.round((rect.x + rect.width) * width), x + 1, width); + var right = clamp( + Math.round((rect.x + rect.width) * width), + x + 1, + width + ); var bottom = clamp( Math.round((rect.y + rect.height) * height), y + 1, @@ -793,7 +811,12 @@ return video.play().catch(function () {}); }; - EffectPlayer.prototype._playCanvasVideo = function (url, options, video, layout) { + EffectPlayer.prototype._playCanvasVideo = function ( + url, + options, + video, + layout + ) { var self = this; if (!this._isCurrent(url)) return Promise.resolve(); var size = layoutCanvasSize(video, layout, options); @@ -831,10 +854,9 @@ video.load(); }; draw(); - return seekVideo(video, 0) - .then(function () { - return video.play().catch(function () {}); - }); + return seekVideo(video, 0).then(function () { + return video.play().catch(function () {}); + }); }; EffectPlayer.prototype._playVideo = function (url, options) { @@ -860,39 +882,27 @@ EffectPlayer.prototype._playSVGA = function (url, options) { var self = this; - return loadScriptOnce(options.svgaScript).then(function () { - if (!self._isCurrent(url)) return undefined; - if (!window.SVGA) throw new Error('svga_unavailable'); - var sourceURL = proxyBinaryURL(url, 'svga', options); - var size = mediaSize(self.container, options); - var canvas = document.createElement('canvas'); - canvas.width = size.width; - canvas.height = size.height; - styleNode(canvas, options); - self._mount(canvas); - var player = new window.SVGA.Player(canvas); - var parser = new window.SVGA.Parser(canvas); - self.cleanup = function () { - if (player.stopAnimation) player.stopAnimation(); - if (player.clear) player.clear(); - }; - return new Promise(function (resolve, reject) { - parser.load( - sourceURL, - function (videoItem) { - if (!self._isCurrent(url)) { - if (player.stopAnimation) player.stopAnimation(); - resolve(); - return; - } - player.setVideoItem(videoItem); - player.startAnimation(); - resolve(); - }, - reject - ); - }); - }); + var sourceURL = proxyBinaryURL(url, 'svga', options); + var size = mediaSize(self.container, options); + var canvas = document.createElement('canvas'); + canvas.width = size.width; + canvas.height = size.height; + styleNode(canvas, options); + self._mount(canvas); + return loadSVGAItemOnce(sourceURL, canvas, options).then( + function (videoItem) { + if (!self._isCurrent(url)) return undefined; + if (!window.SVGA) throw new Error('svga_unavailable'); + var player = new window.SVGA.Player(canvas); + self.cleanup = function () { + if (player.stopAnimation) player.stopAnimation(); + if (player.clear) player.clear(); + }; + player.setVideoItem(videoItem); + player.startAnimation(); + return undefined; + } + ); }; EffectPlayer.prototype._playPAG = function (url, options) { @@ -991,6 +1001,20 @@ create: function (target, options) { return new EffectPlayer(target, options); }, + preload: function (url, options) { + var normalized = extend( + { + svgaScript: DEFAULT_SVGA_SCRIPT, + proxy: true, + }, + options || {} + ); + var type = inferType(url, normalized.type); + if (type !== 'svga') return Promise.resolve(); + var canvas = document.createElement('canvas'); + var sourceURL = proxyBinaryURL(url, 'svga', normalized); + return loadSVGAItemOnce(sourceURL, canvas, normalized); + }, play: function (target, url, options) { var player = new EffectPlayer(target, options); return player.play(url).then(function () { diff --git a/common/game-preloader.js b/common/game-preloader.js new file mode 100644 index 0000000..446e553 --- /dev/null +++ b/common/game-preloader.js @@ -0,0 +1,255 @@ +(function () { + var imageCache = {}; + var binaryCache = {}; + var imagePattern = /\.(png|jpe?g|gif|webp|svg|avif)(\?|#|$)/i; + var svgaPattern = /\.svga(\?|#|$)/i; + + function each(nodes, iteratee) { + Array.prototype.forEach.call(nodes || [], iteratee); + } + + function normalizeURL(url, baseURL) { + var value = String(url || '').trim(); + if ( + !value || + value.indexOf('data:') === 0 || + value.indexOf('blob:') === 0 + ) { + return ''; + } + try { + return new URL(value, baseURL || window.location.href).href; + } catch (_) { + return ''; + } + } + + function addURL(list, seen, url, baseURL) { + var normalized = normalizeURL(url, baseURL); + if (!normalized || seen[normalized]) return; + seen[normalized] = true; + list.push(normalized); + } + + function collectFromCSSText(list, seen, text) { + var pattern = /url\((['"]?)(.*?)\1\)/g; + var match; + while ((match = pattern.exec(text || ''))) { + addURL(list, seen, match[2]); + } + } + + function collectSrcSet(list, seen, value) { + String(value || '') + .split(',') + .forEach(function (item) { + addURL(list, seen, item.trim().split(/\s+/)[0]); + }); + } + + function collectDocumentURLs(list, seen) { + each(document.querySelectorAll('img[src]'), function (img) { + addURL(list, seen, img.getAttribute('src')); + }); + each(document.querySelectorAll('source[srcset]'), function (source) { + collectSrcSet(list, seen, source.getAttribute('srcset')); + }); + each(document.querySelectorAll('style'), function (node) { + collectFromCSSText(list, seen, node.textContent || ''); + }); + each(document.querySelectorAll('[style]'), function (node) { + collectFromCSSText(list, seen, node.getAttribute('style') || ''); + }); + each(document.styleSheets, function (sheet) { + try { + each(sheet.cssRules || [], function (rule) { + collectFromCSSText(list, seen, rule.cssText || ''); + }); + } catch (_) {} + }); + } + + function collectResourceURLs(assetURLs) { + var list = []; + var seen = {}; + (assetURLs || []).forEach(function (url) { + addURL(list, seen, url); + }); + collectDocumentURLs(list, seen); + return list; + } + + function isImageURL(url) { + return imagePattern.test(String(url || '')); + } + + function isSVGAURL(url) { + return svgaPattern.test(String(url || '')); + } + + function preloadImage(url) { + var normalized = normalizeURL(url); + if (!normalized) return Promise.resolve(false); + if (imageCache[normalized]) return imageCache[normalized]; + imageCache[normalized] = new Promise(function (resolve) { + var image = new Image(); + image.onload = function () { + resolve(true); + }; + image.onerror = function () { + resolve(false); + }; + image.decoding = 'async'; + image.src = normalized; + if (image.complete) resolve(true); + }); + return imageCache[normalized]; + } + + function preloadBinary(url) { + var normalized = normalizeURL(url); + if (!normalized) return Promise.resolve(false); + if (binaryCache[normalized]) return binaryCache[normalized]; + binaryCache[normalized] = window + .fetch(normalized, { cache: 'force-cache' }) + .then(function (response) { + return response.ok; + }) + .catch(function () { + return false; + }); + return binaryCache[normalized]; + } + + function preloadSVGA(url, options) { + options = options || {}; + if ( + window.HyAppEffectPlayer && + typeof window.HyAppEffectPlayer.preload === 'function' + ) { + return window.HyAppEffectPlayer.preload(url, { + type: 'svga', + svgaScript: options.svgaScript, + waitForWindowLoad: options.waitForWindowLoad === true, + }) + .then(function () { + return true; + }) + .catch(function () { + return preloadBinary(url); + }); + } + return preloadBinary(url); + } + + function preloadResource(url, options) { + if (isSVGAURL(url)) return preloadSVGA(url, options && options.svga); + if (isImageURL(url)) return preloadImage(url); + return preloadBinary(url); + } + + function setProgress(progress) { + var options = + typeof progress === 'number' + ? { progress: progress } + : progress || {}; + var root = options.root || document.documentElement; + var safeProgress = Math.min( + Math.max(Number(options.progress) || 0, 0), + 1 + ); + var progressNode = + options.progressNode || + document.querySelector('[data-role="loading-progress"]'); + root.style.setProperty('--loading-progress', safeProgress.toFixed(4)); + if (progressNode) { + progressNode.setAttribute( + 'aria-valuenow', + String(Math.round(safeProgress * 100)) + ); + } + } + + function runTasks(tasks, onProgress) { + var completed = 0; + var total = Math.max((tasks && tasks.length) || 0, 1); + var update = + typeof onProgress === 'function' + ? onProgress + : function (value) { + setProgress(value); + }; + update(0); + if (!tasks || !tasks.length) { + update(1); + return Promise.resolve([]); + } + // 资源预加载是体验优化,单个资源失败不应该卡死大厅;失败项会返回 false,进度仍然推进。 + return Promise.all( + tasks.map(function (task) { + return Promise.resolve() + .then(task) + .catch(function () { + return false; + }) + .then(function (value) { + completed += 1; + update(completed / total); + return value; + }); + }) + ); + } + + function complete(options) { + options = options || {}; + var loadingScreen = + options.loadingScreen || + document.querySelector('[data-role="loading-screen"]'); + setProgress({ + root: options.root, + progress: 1, + progressNode: options.progressNode, + }); + if (loadingScreen) { + loadingScreen.classList.add('is-complete'); + loadingScreen.setAttribute('aria-hidden', 'true'); + } + if (options.frame) { + options.frame.classList.remove('is-preloading'); + } + } + + function once(task) { + var promise = null; + return function () { + if (!promise) { + promise = Promise.resolve().then(task); + } + return promise; + }; + } + + function delay(ms) { + return new Promise(function (resolve) { + window.setTimeout(resolve, ms); + }); + } + + window.HyAppGamePreloader = { + addURL: addURL, + collectResourceURLs: collectResourceURLs, + complete: complete, + delay: delay, + isImageURL: isImageURL, + isSVGAURL: isSVGAURL, + normalizeURL: normalizeURL, + once: once, + preloadBinary: preloadBinary, + preloadImage: preloadImage, + preloadResource: preloadResource, + preloadSVGA: preloadSVGA, + runTasks: runTasks, + setProgress: setProgress, + }; +})(); diff --git a/common/locales/ar.json b/common/locales/ar.json index e3f2344..fa7228a 100644 --- a/common/locales/ar.json +++ b/common/locales/ar.json @@ -24,11 +24,13 @@ "rockGame.start": "ابدأ", "rockGame.matching": "جارٍ البحث...", "rockGame.matched": "تم العثور على خصم", + "rockGame.chooseGesture": "اختر الإشارة التي تريد لعبها", "rockGame.winnerReceives": "الفائز يحصل على", "rockGame.rock": "حجر", "rockGame.paper": "ورقة", "rockGame.scissors": "مقص", "rockGame.win": "فوز", + "rockGame.youWin": "فزت", "rockGame.lose": "خسارة", "rockGame.draw": "تعادل", "rockGame.coins": "عملات", @@ -36,6 +38,7 @@ "rockGame.playAgain": "العب مرة أخرى", "rockGame.changeLanguage": "تغيير اللغة", "rockGame.selectStake": "اختر قيمة الرهان", + "rockGame.configFeeNote": "الرسوم {fee}%", "rockGame.apiUnavailable": "واجهة RPS غير متاحة", "rockGame.refundNote": "إذا لم ينضم أحد إلى لعبة حجر ورقة مقص، سيتم رد العملات خلال 10 دقائق.", "roomReward.pageTitle": "فعالية مكافآت الغرفة", diff --git a/common/locales/en.json b/common/locales/en.json index 66df4e2..8386f22 100644 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -24,11 +24,13 @@ "rockGame.start": "START", "rockGame.matching": "Matching...", "rockGame.matched": "Matched", + "rockGame.chooseGesture": "Choose your gesture", "rockGame.winnerReceives": "The winner receives", "rockGame.rock": "Rock", "rockGame.paper": "Paper", "rockGame.scissors": "Scissors", "rockGame.win": "Win", + "rockGame.youWin": "YOU WIN", "rockGame.lose": "Lose", "rockGame.draw": "Draw", "rockGame.coins": "Coins", @@ -36,6 +38,7 @@ "rockGame.playAgain": "Play again", "rockGame.changeLanguage": "Change language", "rockGame.selectStake": "Select a stake", + "rockGame.configFeeNote": "Fee {fee}%", "rockGame.apiUnavailable": "RPS API unavailable", "rockGame.refundNote": "If no one joins the rock-paper-scissors game, the coins will be refunded in 10 minutes.", "roomReward.pageTitle": "Room Reward Event", diff --git a/common/locales/es.json b/common/locales/es.json index fc7fc1d..109978e 100644 --- a/common/locales/es.json +++ b/common/locales/es.json @@ -24,11 +24,13 @@ "rockGame.start": "INICIAR", "rockGame.matching": "Emparejando...", "rockGame.matched": "Emparejado", + "rockGame.chooseGesture": "Elige tu gesto", "rockGame.winnerReceives": "El ganador recibe", "rockGame.rock": "Piedra", "rockGame.paper": "Papel", "rockGame.scissors": "Tijeras", "rockGame.win": "Victoria", + "rockGame.youWin": "GANASTE", "rockGame.lose": "Derrota", "rockGame.draw": "Empate", "rockGame.coins": "Monedas", @@ -36,6 +38,7 @@ "rockGame.playAgain": "Jugar otra vez", "rockGame.changeLanguage": "Cambiar idioma", "rockGame.selectStake": "Selecciona una apuesta", + "rockGame.configFeeNote": "Comisión {fee}%", "rockGame.apiUnavailable": "API de RPS no disponible", "rockGame.refundNote": "Si nadie se une al juego de piedra, papel o tijeras, las monedas se reembolsarán en 10 minutos.", "roomReward.pageTitle": "Evento de recompensas de sala", diff --git a/game/common/game-loading.css b/game/common/game-loading.css new file mode 100644 index 0000000..496b886 --- /dev/null +++ b/game/common/game-loading.css @@ -0,0 +1,69 @@ +.loading-screen { + position: absolute; + inset: 0; + z-index: 200; + overflow: hidden; + background: #030623; + opacity: 1; + pointer-events: auto; + transition: + opacity 320ms ease, + visibility 0s linear 0s; + visibility: visible; +} + +.loading-screen.is-complete { + opacity: 0; + pointer-events: none; + transition: + opacity 320ms ease, + visibility 0s linear 320ms; + visibility: hidden; +} + +.loading-bg { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; + pointer-events: none; + user-select: none; +} + +.loading-progress { + position: absolute; + top: var(--loading-progress-top, 692px); + left: var(--loading-progress-left, 61px); + width: var(--loading-progress-width, 253px); + height: var(--loading-progress-height, 8px); + overflow: hidden; + border: 1px solid rgba(255, 213, 139, 0.76); + border-radius: 999px; + background: + linear-gradient( + 180deg, + rgba(12, 10, 32, 0.8) 0%, + rgba(2, 4, 18, 0.92) 100% + ), + rgba(5, 7, 25, 0.86); + box-shadow: + 0 0 12px rgba(255, 164, 58, 0.32), + inset 0 0 7px rgba(0, 0, 0, 0.78); +} + +.loading-progress-bar { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #ff612f 0%, #ffc957 52%, #3ca4ff 100%); + box-shadow: + 0 0 12px rgba(255, 214, 87, 0.84), + inset 0 1px 1px rgba(255, 255, 255, 0.45); + transform: scaleX(var(--loading-progress, 0)); + transform-origin: left center; + transition: transform 180ms ease; +} diff --git a/game/rock/assets/figma/loading-bg.jpg b/game/rock/assets/figma/loading-bg.jpg new file mode 100644 index 0000000..decd858 Binary files /dev/null and b/game/rock/assets/figma/loading-bg.jpg differ diff --git a/game/rock/index.html b/game/rock/index.html index 066a739..cf32dd0 100644 --- a/game/rock/index.html +++ b/game/rock/index.html @@ -9,6 +9,7 @@ Rock Paper Scissors + @@ -1107,18 +1481,43 @@ data-i18n-aria="rockGame.pageLabel" >
+
+ +
+ +
+
+ -
9:41
@@ -1245,9 +1644,9 @@
- The winner receives + Choose your gesture
?
@@ -1298,8 +1697,23 @@ data-stake="8000" aria-pressed="false" > - - 8,000 + + + + + 8,000
-
- If no one joins the rock-paper-scissors game, the - coins will be refunded in 10 minutes. -
+
Fee 5%
@@ -1448,6 +1901,33 @@
+ + + +
@@ -1595,6 +2075,7 @@ window.HyAppI18nSupported = ['en', 'ar', 'es']; + - + +