Initial commit
28
SQ_Word_Hint-Image/appmanifest.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "SQ_Word_Hint-Image",
|
||||||
|
"short_name": "SQ_Word_Hint-Image",
|
||||||
|
"start_url": "index.html",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [{
|
||||||
|
"src": "icon-16.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
"type": "image/png"
|
||||||
|
}, {
|
||||||
|
"src": "icon-32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
|
}, {
|
||||||
|
"src": "icon-128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
}, {
|
||||||
|
"src": "icon-256.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
}, {
|
||||||
|
"src": "icon-256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
}]
|
||||||
|
}
|
||||||
BIN
SQ_Word_Hint-Image/bg.mp4
Normal file
26864
SQ_Word_Hint-Image/c2runtime.js
Normal file
1
SQ_Word_Hint-Image/data.js
Normal file
BIN
SQ_Word_Hint-Image/icon-114.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
SQ_Word_Hint-Image/icon-128.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
SQ_Word_Hint-Image/icon-16.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
SQ_Word_Hint-Image/icon-256.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
SQ_Word_Hint-Image/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
SQ_Word_Hint-Image/images/answers-sheet0.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
SQ_Word_Hint-Image/images/bgm-sheet0.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
SQ_Word_Hint-Image/images/bgm-sheet1.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
SQ_Word_Hint-Image/images/black-sheet0.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
SQ_Word_Hint-Image/images/board-sheet0.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
SQ_Word_Hint-Image/images/btn_play-sheet1.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
SQ_Word_Hint-Image/images/correct-sheet0.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
SQ_Word_Hint-Image/images/help-sheet0.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
SQ_Word_Hint-Image/images/hint-sheet0.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
SQ_Word_Hint-Image/images/imageframe-sheet0.png
Normal file
|
After Width: | Height: | Size: 469 KiB |
BIN
SQ_Word_Hint-Image/images/khung_timer-sheet0.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
SQ_Word_Hint-Image/images/newwords-sheet0.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
SQ_Word_Hint-Image/images/pause-sheet0.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
SQ_Word_Hint-Image/images/pause-sheet1.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
SQ_Word_Hint-Image/images/pauseicon-sheet0.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
SQ_Word_Hint-Image/images/sena_ui_frame_intro-sheet0.png
Normal file
|
After Width: | Height: | Size: 155 B |
BIN
SQ_Word_Hint-Image/images/setting-sheet0.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
SQ_Word_Hint-Image/images/slot-sheet0.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
SQ_Word_Hint-Image/images/sprite-sheet0.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
SQ_Word_Hint-Image/images/submit-sheet0.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
SQ_Word_Hint-Image/images/wrong-sheet0.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
140
SQ_Word_Hint-Image/index.html
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<title>SQ_Word_Hint-Image</title>
|
||||||
|
|
||||||
|
<!-- Standardised web app manifest -->
|
||||||
|
<link rel="manifest" href="appmanifest.json" />
|
||||||
|
|
||||||
|
<!-- Allow fullscreen mode on iOS devices. (These are Apple specific meta tags.) -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
|
<link rel="apple-touch-icon" sizes="256x256" href="icon-256.png" />
|
||||||
|
<meta name="HandheldFriendly" content="true" />
|
||||||
|
|
||||||
|
<!-- Chrome for Android web app tags -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="shortcut icon" sizes="256x256" href="icon-256.png" />
|
||||||
|
|
||||||
|
<!-- All margins and padding must be zero for the canvas to fill the screen. -->
|
||||||
|
<style type="text/css">
|
||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
-ms-touch-action: none;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
touch-action-delay: none;
|
||||||
|
touch-action: none;
|
||||||
|
-ms-touch-action: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="fb-root"></div>
|
||||||
|
<div style="width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: -1;">
|
||||||
|
<!--<img src = "./bg.jpg" style="width:100%; height: 100%; object-fit: cover;"/> -->
|
||||||
|
<video autoplay="" loop="" muted="" style="width : 100%; height: 100%;object-fit: cover;" src="bg.mp4">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Issue a warning if trying to preview an exported project on disk.
|
||||||
|
(function(){
|
||||||
|
// Check for running exported on file protocol
|
||||||
|
if (window.location.protocol.substr(0, 4) === "file")
|
||||||
|
{
|
||||||
|
alert("Exported games won't work until you upload them. (When running on the file:/// protocol, browsers block many features from working for security reasons.)");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- The canvas must be inside a div called c2canvasdiv -->
|
||||||
|
<div id="c2canvasdiv">
|
||||||
|
|
||||||
|
<!-- The canvas the project will render to. If you change its ID, don't forget to change the
|
||||||
|
ID the runtime looks for in the jQuery events above (ready() and cr_sizeCanvas()). -->
|
||||||
|
<canvas id="c2canvas" width="1200" height="1200">
|
||||||
|
<!-- This text is displayed if the visitor's browser does not support HTML5.
|
||||||
|
You can change it, but it is a good idea to link to a description of a browser
|
||||||
|
and provide some links to download some popular HTML5-compatible browsers. -->
|
||||||
|
<h1>Your browser does not appear to support HTML5. Try upgrading your browser to the latest version. <a href="http://www.whatbrowser.org">What is a browser?</a>
|
||||||
|
<br/><br/><a href="http://www.microsoft.com/windows/internet-explorer/default.aspx">Microsoft Internet Explorer</a><br/>
|
||||||
|
<a href="http://www.mozilla.com/firefox/">Mozilla Firefox</a><br/>
|
||||||
|
<a href="http://www.google.com/chrome/">Google Chrome</a><br/>
|
||||||
|
<a href="http://www.apple.com/safari/download/">Apple Safari</a></h1>
|
||||||
|
</canvas>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pages load faster with scripts at the bottom -->
|
||||||
|
|
||||||
|
<!-- Construct 2 exported games require jQuery. -->
|
||||||
|
<script src="jquery-3.4.1.min.js"></script>
|
||||||
|
<script src="tdv_sdk.js"></script>
|
||||||
|
<script src="sena_sdk.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- The runtime script. You can rename it, but don't forget to rename the reference here as well.
|
||||||
|
This file will have been minified and obfuscated if you enabled "Minify script" during export. -->
|
||||||
|
<script src="c2runtime.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Start the Construct 2 project running on window load.
|
||||||
|
jQuery(document).ready(function ()
|
||||||
|
{
|
||||||
|
// Create new runtime using the c2canvas
|
||||||
|
cr_createRuntime("c2canvas");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause and resume on page becoming visible/invisible
|
||||||
|
function onVisibilityChanged() {
|
||||||
|
if (document.hidden || document.mozHidden || document.webkitHidden || document.msHidden)
|
||||||
|
cr_setSuspended(true);
|
||||||
|
else
|
||||||
|
cr_setSuspended(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChanged, false);
|
||||||
|
document.addEventListener("mozvisibilitychange", onVisibilityChanged, false);
|
||||||
|
document.addEventListener("webkitvisibilitychange", onVisibilityChanged, false);
|
||||||
|
document.addEventListener("msvisibilitychange", onVisibilityChanged, false);
|
||||||
|
|
||||||
|
function OnRegisterSWError(e)
|
||||||
|
{
|
||||||
|
console.warn("Failed to register service worker: ", e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime calls this global method when ready to start caching (i.e. after startup).
|
||||||
|
// This registers the service worker which caches resources for offline support.
|
||||||
|
window.C2_RegisterSW = function C2_RegisterSW()
|
||||||
|
{
|
||||||
|
if (!navigator.serviceWorker)
|
||||||
|
return; // no SW support, ignore call
|
||||||
|
|
||||||
|
try {
|
||||||
|
navigator.serviceWorker.register("sw.js", { scope: "./" })
|
||||||
|
.then(function (reg)
|
||||||
|
{
|
||||||
|
console.log("Registered service worker on " + reg.scope);
|
||||||
|
})
|
||||||
|
.catch(OnRegisterSWError);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
OnRegisterSWError(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2
SQ_Word_Hint-Image/jquery-3.4.1.min.js
vendored
Normal file
BIN
SQ_Word_Hint-Image/loading-logo.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
SQ_Word_Hint-Image/media/alert-234711.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/bg.m4a
Normal file
BIN
SQ_Word_Hint-Image/media/bg.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/bubble-pop-389501.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/button-124476.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/card_drag.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/card_flips.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/card_swipe.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/click-234708.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/collect-5930.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/correct.m4a
Normal file
BIN
SQ_Word_Hint-Image/media/correct.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/error-010-206498.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/error-08-206492.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/interface-2-126517.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/pick-92276.ogg
Normal file
BIN
SQ_Word_Hint-Image/media/pop-reverb-423718.ogg
Normal file
84
SQ_Word_Hint-Image/offline.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"version": 1769505800,
|
||||||
|
"fileList": [
|
||||||
|
"data.js",
|
||||||
|
"c2runtime.js",
|
||||||
|
"jquery-3.4.1.min.js",
|
||||||
|
"offlineClient.js",
|
||||||
|
"images/answers-sheet0.png",
|
||||||
|
"images/slot-sheet0.png",
|
||||||
|
"images/submit-sheet0.png",
|
||||||
|
"images/txt_instructions.png",
|
||||||
|
"images/btn_submit2-sheet0.png",
|
||||||
|
"images/btn_next-sheet0.png",
|
||||||
|
"images/btn_play-sheet0.png",
|
||||||
|
"images/btn_play-sheet1.png",
|
||||||
|
"images/frame_door-sheet0.png",
|
||||||
|
"images/frame_door_left-sheet0.png",
|
||||||
|
"images/frame_door_right-sheet0.png",
|
||||||
|
"images/hand_right-sheet0.png",
|
||||||
|
"images/hand_left-sheet0.png",
|
||||||
|
"images/sprite-sheet0.png",
|
||||||
|
"images/khung_thoai-sheet0.png",
|
||||||
|
"images/khung-sheet0.png",
|
||||||
|
"images/avatar-sheet0.png",
|
||||||
|
"images/frame_score-sheet0.png",
|
||||||
|
"images/sena_ui_frame_intro-sheet0.png",
|
||||||
|
"images/wrong-sheet0.png",
|
||||||
|
"images/correct-sheet0.png",
|
||||||
|
"images/setting-sheet0.png",
|
||||||
|
"images/khung_score-sheet0.png",
|
||||||
|
"images/khung_timer-sheet0.png",
|
||||||
|
"images/pause-sheet0.png",
|
||||||
|
"images/pause-sheet1.png",
|
||||||
|
"images/help-sheet0.png",
|
||||||
|
"images/board-sheet0.png",
|
||||||
|
"images/bgm-sheet0.png",
|
||||||
|
"images/bgm-sheet1.png",
|
||||||
|
"images/pauseicon-sheet0.png",
|
||||||
|
"images/black-sheet0.png",
|
||||||
|
"images/hint-sheet0.png",
|
||||||
|
"images/imageframe-sheet0.png",
|
||||||
|
"images/newwords-sheet0.png",
|
||||||
|
"media/bg.m4a",
|
||||||
|
"media/bg.ogg",
|
||||||
|
"media/click.m4a",
|
||||||
|
"media/click.ogg",
|
||||||
|
"media/correct.m4a",
|
||||||
|
"media/correct.ogg",
|
||||||
|
"media/fail.m4a",
|
||||||
|
"media/fail.ogg",
|
||||||
|
"media/alert-234711.ogg",
|
||||||
|
"media/bubble-pop-389501.ogg",
|
||||||
|
"media/button-124476.ogg",
|
||||||
|
"media/click-234708.ogg",
|
||||||
|
"media/collect-5930.ogg",
|
||||||
|
"media/edugamery-music-13.ogg",
|
||||||
|
"media/error-010-206498.ogg",
|
||||||
|
"media/error-08-206492.ogg",
|
||||||
|
"media/fail-234710.ogg",
|
||||||
|
"media/interface-2-126517.ogg",
|
||||||
|
"media/material-buy-success-394517.ogg",
|
||||||
|
"media/pick-92276.ogg",
|
||||||
|
"media/pop-reverb-423718.ogg",
|
||||||
|
"media/retro-explode-1-236678.ogg",
|
||||||
|
"media/sample_input_typing01_kofi_by_miraclei-363634.ogg",
|
||||||
|
"media/edugamery-music-18.ogg",
|
||||||
|
"media/edugamery-music-20.ogg",
|
||||||
|
"media/edugamery-music-5.ogg",
|
||||||
|
"media/edugamery-music-6.ogg",
|
||||||
|
"media/fm-freemusic-give-me-a-smile(chosic.com).ogg",
|
||||||
|
"media/card_drag.ogg",
|
||||||
|
"media/card_flips.ogg",
|
||||||
|
"media/card_swipe.ogg",
|
||||||
|
"icon-16.png",
|
||||||
|
"icon-32.png",
|
||||||
|
"icon-114.png",
|
||||||
|
"icon-128.png",
|
||||||
|
"icon-256.png",
|
||||||
|
"loading-logo.png",
|
||||||
|
"bg.mp4",
|
||||||
|
"tdv_sdk.js",
|
||||||
|
"sena_sdk.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
53
SQ_Word_Hint-Image/offlineClient.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
class OfflineClient
|
||||||
|
{
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
// Create a BroadcastChannel, if supported.
|
||||||
|
this._broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel("offline"));
|
||||||
|
|
||||||
|
// Queue of messages received before a message callback is set.
|
||||||
|
this._queuedMessages = [];
|
||||||
|
|
||||||
|
// The message callback.
|
||||||
|
this._onMessageCallback = null;
|
||||||
|
|
||||||
|
// If BroadcastChannel is supported, listen for messages.
|
||||||
|
if (this._broadcastChannel)
|
||||||
|
this._broadcastChannel.onmessage = (e => this._OnBroadcastChannelMessage(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
_OnBroadcastChannelMessage(e)
|
||||||
|
{
|
||||||
|
// Have a message callback set: just forward the call.
|
||||||
|
if (this._onMessageCallback)
|
||||||
|
{
|
||||||
|
this._onMessageCallback(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise the app hasn't loaded far enough to set a message callback.
|
||||||
|
// Buffer the incoming messages to replay when the app sets a callback.
|
||||||
|
this._queuedMessages.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetMessageCallback(f)
|
||||||
|
{
|
||||||
|
this._onMessageCallback = f;
|
||||||
|
|
||||||
|
// Replay any queued messages through the handler, then clear the queue.
|
||||||
|
for (let e of this._queuedMessages)
|
||||||
|
this._onMessageCallback(e);
|
||||||
|
|
||||||
|
this._queuedMessages.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the offline client ASAP so we receive and start queueing any messages the SW broadcasts.
|
||||||
|
window.OfflineClientInfo = new OfflineClient();
|
||||||
|
|
||||||
|
}());
|
||||||
|
|
||||||
600
SQ_Word_Hint-Image/sena_sdk.js
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* Sena SDK Constructor
|
||||||
|
* @param {Object} config - Configuration object for the SDK
|
||||||
|
* @param {Object} config.data - Quiz data containing question, options, and answer
|
||||||
|
*/
|
||||||
|
function SenaSDK(gid = 'G2510S1T30') {
|
||||||
|
// Initialize data
|
||||||
|
this.data = null;
|
||||||
|
this.correctAnswer = null;
|
||||||
|
this.gameCode = gid;
|
||||||
|
// Initialize properties
|
||||||
|
this.timeLimit = 0;
|
||||||
|
this.shuffle = true;
|
||||||
|
// tracking time in game
|
||||||
|
this.startTime = 0;
|
||||||
|
this.endTime = 0;
|
||||||
|
// Initialize Web Speech API
|
||||||
|
this.speechSynthesis = window.speechSynthesis;
|
||||||
|
this.currentUtterance = null;
|
||||||
|
this.voiceSettings = {
|
||||||
|
lang: 'en-US',
|
||||||
|
rate: 1.0, // Speed (0.1 to 10)
|
||||||
|
pitch: 1.0, // Pitch (0 to 2)
|
||||||
|
volume: 1.0 // Volume (0 to 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle array using Fisher-Yates algorithm
|
||||||
|
* @param {Array} array - Array to shuffle
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.shuffleArray = function(array) {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SenaSDK.prototype.load = function(callback,template = 'G2510S1T30') {
|
||||||
|
let self = this;
|
||||||
|
// get parameter LID from URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const LID = urlParams.get('LID');
|
||||||
|
if (LID) {
|
||||||
|
self.gameCode = LID;
|
||||||
|
};
|
||||||
|
fetch(`https://senaai.tech/sample/${self.gameCode}.json`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
self.data = data.data;
|
||||||
|
self.correctAnswer = data.answer || null;
|
||||||
|
// based on game code, set timeLimit and shuffle
|
||||||
|
const gameCode = self.gameCode || template;
|
||||||
|
const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
|
||||||
|
const match = gameCode.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const shuffleFlag = match[5] !== undefined ? match[5] : '1';
|
||||||
|
const timeStr = match[6] !== undefined ? match[6] : '0';
|
||||||
|
self.shuffle = (shuffleFlag === '1');
|
||||||
|
self.timeLimit = parseInt(timeStr, 10);
|
||||||
|
}
|
||||||
|
if (callback) callback(true);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading LID data:', error);
|
||||||
|
if (callback) callback(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Generate comprehensive developer guide based on game code
|
||||||
|
* @returns {string} Developer guide with implementation instructions
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.guide = function() {
|
||||||
|
let self = this;
|
||||||
|
const gameCode = self.gameCode || 'G2510S1T30';
|
||||||
|
const data = self.data || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex giải thích:
|
||||||
|
* ^G([1-5])([2-9])([0-2])([0-2]) : Bắt buộc (Loại, Số lượng, Q, O)
|
||||||
|
* (?:S([0-1]))? : Không bắt buộc, mặc định S1
|
||||||
|
* (?:T(\d+))? : Không bắt buộc, mặc định T0
|
||||||
|
*/
|
||||||
|
const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
|
||||||
|
const match = gameCode.match(regex);
|
||||||
|
|
||||||
|
if (!match) return "Mã game không hợp lệ! Định dạng chuẩn: Gxxxx hoặc GxxxxSxTxx";
|
||||||
|
|
||||||
|
const category = match[1];
|
||||||
|
const count = match[2];
|
||||||
|
const qIdx = match[3];
|
||||||
|
const oIdx = match[4];
|
||||||
|
const shuffle = match[5] !== undefined ? match[5] : '1';
|
||||||
|
const time = match[6] !== undefined ? match[6] : '0';
|
||||||
|
|
||||||
|
const types = { '0': 'Text', '1': 'Image', '2': 'Audio' };
|
||||||
|
let guide = '';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
guide += `╔════════════════════════════════════════════════════════════════════════════╗\n`;
|
||||||
|
guide += `║ SENA SDK - DEVELOPER GUIDE: ${gameCode.padEnd(37)}║\n`;
|
||||||
|
guide += `╚════════════════════════════════════════════════════════════════════════════╝\n\n`;
|
||||||
|
|
||||||
|
// Game Analysis
|
||||||
|
guide += `📊 GAME ANALYSIS\n`;
|
||||||
|
guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
||||||
|
|
||||||
|
let gameName = '', instruction = '', displayMode = '';
|
||||||
|
|
||||||
|
switch(category) {
|
||||||
|
case '1':
|
||||||
|
gameName = "Quiz (Trắc nghiệm)";
|
||||||
|
instruction = `Người dùng chọn 1 đáp án đúng trong ${count} options`;
|
||||||
|
displayMode = `Question: ${types[qIdx]} → Options: ${types[oIdx]}`;
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
if (qIdx === '0' && oIdx === '0') {
|
||||||
|
gameName = "Sort Word (Sắp xếp từ)";
|
||||||
|
instruction = `Sắp xếp ${count} từ/ký tự thành chuỗi hoàn chỉnh`;
|
||||||
|
} else {
|
||||||
|
gameName = "Sequences (Sắp xếp chuỗi)";
|
||||||
|
instruction = `Sắp xếp ${count} items theo đúng thứ tự`;
|
||||||
|
}
|
||||||
|
displayMode = `Hint: ${types[qIdx]} → Items: ${types[oIdx]}`;
|
||||||
|
break;
|
||||||
|
case '3':
|
||||||
|
if (qIdx === oIdx) {
|
||||||
|
gameName = "Memory Card (Trí nhớ)";
|
||||||
|
instruction = `Lật và ghép ${count} cặp thẻ giống nhau`;
|
||||||
|
} else {
|
||||||
|
gameName = "Matching (Nối cặp)";
|
||||||
|
instruction = `Nối ${count} items từ 2 nhóm với nhau`;
|
||||||
|
}
|
||||||
|
displayMode = `Group A: ${types[qIdx]} ↔ Group B: ${types[oIdx]}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
guide += `Game Type : ${gameName}\n`;
|
||||||
|
guide += `Objective : ${instruction}\n`;
|
||||||
|
guide += `Display Mode : ${displayMode}\n`;
|
||||||
|
guide += `Items Count : ${count}\n`;
|
||||||
|
guide += `Shuffle : ${shuffle === '1' ? 'YES (call sdk.start() to shuffle)' : 'NO (S0)'}\n`;
|
||||||
|
guide += `Time Limit : ${time === '0' ? 'Unlimited' : time + ' seconds'}\n`;
|
||||||
|
|
||||||
|
if (data.hint && data.hint.type) {
|
||||||
|
guide += `Hint Type : ${data.hint.type}\n`;
|
||||||
|
guide += `Hint Count : ${Array.isArray(data.hint.value) ? data.hint.value.length : '1'}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
guide += `\n🔧 IMPLEMENTATION STEPS\n`;
|
||||||
|
guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`;
|
||||||
|
|
||||||
|
// Step 1: Initialize
|
||||||
|
guide += `1️⃣ INITIALIZE SDK\n`;
|
||||||
|
guide += ` var sdk = new SenaSDK('${gameCode}');\n`;
|
||||||
|
guide += ` sdk.load(function(success) {\n`;
|
||||||
|
guide += ` if (success) sdk.start();\n`;
|
||||||
|
guide += ` });\n\n`;
|
||||||
|
|
||||||
|
// Step 2: Display based on game type
|
||||||
|
guide += `2️⃣ DISPLAY UI\n`;
|
||||||
|
|
||||||
|
if (category === '1') {
|
||||||
|
// Quiz
|
||||||
|
guide += ` // Display Question\n`;
|
||||||
|
if (qIdx === '0') {
|
||||||
|
guide += ` var questionText = sdk.getQuestionValue();\n`;
|
||||||
|
guide += ` displayText(questionText); // Show text\n`;
|
||||||
|
} else if (qIdx === '1') {
|
||||||
|
guide += ` var questionImg = sdk.getQuestionValue();\n`;
|
||||||
|
guide += ` displayImage(questionImg); // Show image URL\n`;
|
||||||
|
} else if (qIdx === '2') {
|
||||||
|
guide += ` sdk.playVoice('question'); // Auto play audio\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
guide += `\n // Display Options\n`;
|
||||||
|
guide += ` var optionsCount = sdk.getOptionsCount(); // ${count} items\n`;
|
||||||
|
guide += ` for (var i = 0; i < optionsCount; i++) {\n`;
|
||||||
|
if (oIdx === '0') {
|
||||||
|
guide += ` var optionText = sdk.getOptionsValue(i).text;\n`;
|
||||||
|
guide += ` createButton(optionText, i); // Show text button\n`;
|
||||||
|
} else if (oIdx === '1') {
|
||||||
|
guide += ` var optionImg = sdk.getOptionsValue(i).image;\n`;
|
||||||
|
guide += ` createImageButton(optionImg, i); // Show image button\n`;
|
||||||
|
} else if (oIdx === '2') {
|
||||||
|
guide += ` createAudioButton(i); // Button to play audio\n`;
|
||||||
|
guide += ` // onClick: sdk.playVoice('option' + (i+1));\n`;
|
||||||
|
}
|
||||||
|
guide += ` }\n`;
|
||||||
|
|
||||||
|
} else if (category === '2') {
|
||||||
|
// Sort/Sequences
|
||||||
|
guide += ` // Display Hint (if exists)\n`;
|
||||||
|
if (qIdx === '0') {
|
||||||
|
guide += ` var hintText = sdk.getQuestionValue() || sdk.getRequestValue();\n`;
|
||||||
|
guide += ` if (hintText) displayHint(hintText);\n`;
|
||||||
|
} else if (qIdx === '1') {
|
||||||
|
guide += ` var hintImg = sdk.getQuestionValue();\n`;
|
||||||
|
guide += ` if (hintImg) displayHintImage(hintImg);\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
guide += `\n // Display Draggable Items\n`;
|
||||||
|
guide += ` var itemsCount = sdk.getOptionsCount();\n`;
|
||||||
|
guide += ` for (var i = 0; i < itemsCount; i++) {\n`;
|
||||||
|
if (oIdx === '0') {
|
||||||
|
guide += ` var itemText = sdk.getOptionsValue(i).text;\n`;
|
||||||
|
guide += ` createDraggableText(itemText, i);\n`;
|
||||||
|
} else if (oIdx === '1') {
|
||||||
|
guide += ` var itemImg = sdk.getOptionsValue(i).image;\n`;
|
||||||
|
guide += ` createDraggableImage(itemImg, i);\n`;
|
||||||
|
}
|
||||||
|
guide += ` }\n`;
|
||||||
|
guide += ` // User drags to reorder items\n`;
|
||||||
|
|
||||||
|
} else if (category === '3') {
|
||||||
|
// Memory/Matching
|
||||||
|
guide += ` var itemsCount = sdk.getOptionsCount();\n`;
|
||||||
|
if (qIdx === oIdx) {
|
||||||
|
guide += ` // Memory Card - Create pairs\n`;
|
||||||
|
guide += ` var allCards = []; // Duplicate items for pairs\n`;
|
||||||
|
guide += ` for (var i = 0; i < itemsCount; i++) {\n`;
|
||||||
|
guide += ` allCards.push(sdk.getOptionsValue(i));\n`;
|
||||||
|
guide += ` allCards.push(sdk.getOptionsValue(i)); // Duplicate\n`;
|
||||||
|
guide += ` }\n`;
|
||||||
|
guide += ` shuffleArray(allCards);\n`;
|
||||||
|
guide += ` // Create face-down cards, flip on click\n`;
|
||||||
|
} else {
|
||||||
|
guide += ` // Matching - Create two groups\n`;
|
||||||
|
guide += ` for (var i = 0; i < itemsCount; i++) {\n`;
|
||||||
|
if (qIdx === '0') {
|
||||||
|
guide += ` var leftText = sdk.getOptionsValue(i).text;\n`;
|
||||||
|
guide += ` createLeftItem(leftText, i);\n`;
|
||||||
|
} else if (qIdx === '1') {
|
||||||
|
guide += ` var leftImg = sdk.getOptionsValue(i).image;\n`;
|
||||||
|
guide += ` createLeftItem(leftImg, i);\n`;
|
||||||
|
}
|
||||||
|
if (oIdx === '0') {
|
||||||
|
guide += ` var rightText = sdk.getOptionsValue(i).text; // Matching pair\n`;
|
||||||
|
guide += ` createRightItem(rightText, i);\n`;
|
||||||
|
} else if (oIdx === '1') {
|
||||||
|
guide += ` var rightImg = sdk.getOptionsValue(i).image;\n`;
|
||||||
|
guide += ` createRightItem(rightImg, i);\n`;
|
||||||
|
}
|
||||||
|
guide += ` }\n`;
|
||||||
|
guide += ` // User draws lines to match pairs\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Handle Hint
|
||||||
|
if (data.hint && data.hint.type) {
|
||||||
|
guide += `\n3️⃣ HANDLE HINT (Optional)\n`;
|
||||||
|
guide += ` var hintType = sdk.getHintType(); // "${data.hint.type}"\n`;
|
||||||
|
if (data.hint.type === 'display') {
|
||||||
|
guide += ` var hintCount = sdk.getHintCount();\n`;
|
||||||
|
guide += ` for (var i = 0; i < hintCount; i++) {\n`;
|
||||||
|
guide += ` var hintItem = sdk.getHintValue(i);\n`;
|
||||||
|
guide += ` displayHintItem(hintItem, i); // Show each hint\n`;
|
||||||
|
guide += ` }\n`;
|
||||||
|
} else if (data.hint.type === 'audio') {
|
||||||
|
guide += ` var hintAudio = sdk.getHintValue();\n`;
|
||||||
|
guide += ` createHintButton(hintAudio); // Play audio hint\n`;
|
||||||
|
} else if (data.hint.type === 'text') {
|
||||||
|
guide += ` var hintText = sdk.getHintValue();\n`;
|
||||||
|
guide += ` displayHintText(hintText);\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Check Answer
|
||||||
|
const stepNum = data.hint ? '4️⃣' : '3️⃣';
|
||||||
|
guide += `\n${stepNum} CHECK ANSWER\n`;
|
||||||
|
|
||||||
|
if (category === '1') {
|
||||||
|
guide += ` // User clicks an option\n`;
|
||||||
|
guide += ` function onOptionClick(selectedIndex) {\n`;
|
||||||
|
guide += ` var userAnswer = sdk.getOptionsValue(selectedIndex).text;\n`;
|
||||||
|
guide += ` var result = sdk.end(userAnswer, function(isCorrect) {\n`;
|
||||||
|
guide += ` if (isCorrect) showSuccess();\n`;
|
||||||
|
guide += ` else showError();\n`;
|
||||||
|
guide += ` });\n`;
|
||||||
|
guide += ` }\n`;
|
||||||
|
} else if (category === '2') {
|
||||||
|
guide += ` // User finishes sorting\n`;
|
||||||
|
guide += ` function onSubmitOrder(orderedArray) {\n`;
|
||||||
|
guide += ` var answerStr = orderedArray.map(item => item.text).join('|');\n`;
|
||||||
|
guide += ` sdk.end(answerStr, function(isCorrect) {\n`;
|
||||||
|
guide += ` if (isCorrect) showSuccess();\n`;
|
||||||
|
guide += ` else showCorrectOrder();\n`;
|
||||||
|
guide += ` });\n`;
|
||||||
|
guide += ` }\n`;
|
||||||
|
} else if (category === '3') {
|
||||||
|
guide += ` // User completes all matches/pairs\n`;
|
||||||
|
guide += ` function onAllMatched(matchedPairs) {\n`;
|
||||||
|
guide += ` var answerStr = matchedPairs.map(p => p.text).join('|');\n`;
|
||||||
|
guide += ` sdk.end(answerStr, function(isCorrect) {\n`;
|
||||||
|
guide += ` showResult(isCorrect);\n`;
|
||||||
|
guide += ` });\n`;
|
||||||
|
guide += ` }\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Timer
|
||||||
|
if (time !== '0') {
|
||||||
|
const nextStep = data.hint ? '5️⃣' : '4️⃣';
|
||||||
|
guide += `\n${nextStep} TIMER COUNTDOWN\n`;
|
||||||
|
guide += ` var timeLimit = sdk.timeLimit; // ${time} seconds\n`;
|
||||||
|
guide += ` startCountdown(timeLimit);\n`;
|
||||||
|
guide += ` // If time runs out before sdk.end(), user fails\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Reference
|
||||||
|
guide += `\n\n📚 KEY API METHODS\n`;
|
||||||
|
guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
||||||
|
guide += `sdk.load(callback, template) - Load data from server\n`;
|
||||||
|
guide += `sdk.start() - Start game, shuffle if needed\n`;
|
||||||
|
guide += `sdk.getQuestionValue() - Get question text/image/audio\n`;
|
||||||
|
guide += `sdk.getQuestionType() - Get question type (text/image/audio)\n`;
|
||||||
|
guide += `sdk.getOptionsCount() - Get number of options\n`;
|
||||||
|
guide += `sdk.getOptionsValue(index) - Get option object at index\n`;
|
||||||
|
guide += `sdk.getOptionsType() - Get options type\n`;
|
||||||
|
guide += `sdk.getHintType() - Get hint type\n`;
|
||||||
|
guide += `sdk.getHintValue(index) - Get hint value/array item\n`;
|
||||||
|
guide += `sdk.getHintCount() - Get hint items count\n`;
|
||||||
|
guide += `sdk.playVoice(type) - Play TTS (question/option1/hint)\n`;
|
||||||
|
guide += `sdk.end(answer, callback) - Check answer & return result\n`;
|
||||||
|
guide += `sdk.timeLimit - Time limit in seconds\n`;
|
||||||
|
guide += `sdk.shuffle - Whether to shuffle options\n`;
|
||||||
|
|
||||||
|
guide += `\n\n💡 TIPS\n`;
|
||||||
|
guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
||||||
|
if (shuffle === '1') {
|
||||||
|
guide += `• Options are shuffled after sdk.start() - display in new order\n`;
|
||||||
|
}
|
||||||
|
if (time !== '0') {
|
||||||
|
guide += `• Implement timer UI and auto-submit when time expires\n`;
|
||||||
|
}
|
||||||
|
guide += `• Use Web Speech API: sdk.playVoice() for TTS in English\n`;
|
||||||
|
guide += `• Multiple answers format: "answer1|answer2|answer3"\n`;
|
||||||
|
guide += `• sdk.end() returns: {isCorrect, duration, correctAnswer, userAnswer}\n`;
|
||||||
|
|
||||||
|
guide += `\n${'═'.repeat(76)}\n`;
|
||||||
|
|
||||||
|
return guide;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get the question text
|
||||||
|
* @returns {string} Question or request text
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getQuestionValue = function() {
|
||||||
|
if (this.data.question && this.data.question !== "") {
|
||||||
|
return this.data.question;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get the question type
|
||||||
|
* @returns {string} Question type (text, image, audio)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getQuestionType = function() {
|
||||||
|
let self = this;
|
||||||
|
// based on game code, determine question type
|
||||||
|
const gameCode = self.gameCode || 'G2510S1T30';
|
||||||
|
const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
|
||||||
|
const match = gameCode.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const qIdx = match[3];
|
||||||
|
const types = { '0': 'text', '1': 'image', '2': 'audio' };
|
||||||
|
return types[qIdx] || 'text';
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the request value
|
||||||
|
* @returns {string} Request text
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getRequestValue = function() {
|
||||||
|
if (this.data && this.data.request && this.data.request !== "") {
|
||||||
|
return this.data.request;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the request type (same as question type)
|
||||||
|
* @returns {string} Request type (text, image, audio)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getRequestType = function() {
|
||||||
|
return this.getQuestionType();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of options
|
||||||
|
* @returns {number} Number of options
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getOptionsCount = function() {
|
||||||
|
return this.data.options.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get options type based on game code
|
||||||
|
* @returns {string} Options type (text, image, audio)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getOptionsType = function() {
|
||||||
|
let self = this;
|
||||||
|
// based on game code, determine options type
|
||||||
|
const gameCode = self.gameCode || 'G2510S1T30';
|
||||||
|
const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
|
||||||
|
const match = gameCode.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const oIdx = match[4];
|
||||||
|
const types = { '0': 'text', '1': 'image', '2': 'audio' };
|
||||||
|
return types[oIdx] || 'text';
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get option value by index based on options type from game code
|
||||||
|
* @param {number} index - Option index
|
||||||
|
* @returns {string} Option value (text, image URL, or audio URL based on game code)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getOptionsValue = function(index) {
|
||||||
|
return this.data.options[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hint type
|
||||||
|
* @returns {string} Hint type (e.g., 'display', 'audio', 'text', or empty string if no hint)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getHintType = function() {
|
||||||
|
if (this.data && this.data.hint && this.data.hint.type) {
|
||||||
|
return this.data.hint.type;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hint count (number of elements if hint is an array, particularly for display type)
|
||||||
|
* @returns {number} Number of elements in hint array, or 1 if not an array, or 0 if no hint
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getHintCount = function() {
|
||||||
|
const hintValue = this.getHintValue();
|
||||||
|
if (hintValue === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (Array.isArray(hintValue)) {
|
||||||
|
return hintValue.length;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hint value
|
||||||
|
* @param {number} index - Optional index for array hints (display type)
|
||||||
|
* @returns {*} Hint value (string, array element, or null if no hint)
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.getHintValue = function(index) {
|
||||||
|
if (this.data && this.data.hint && this.data.hint.value !== undefined) {
|
||||||
|
const hintValue = this.data.hint.value;
|
||||||
|
const hintType = this.getHintType();
|
||||||
|
|
||||||
|
// If hint type is display and value is array, return specific index
|
||||||
|
if (hintType === 'display' && Array.isArray(hintValue)) {
|
||||||
|
if (index !== undefined && index >= 0 && index < hintValue.length) {
|
||||||
|
return hintValue[index];
|
||||||
|
}
|
||||||
|
// If no index provided or invalid, return the whole array
|
||||||
|
return hintValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For audio or text type, return the value directly
|
||||||
|
return hintValue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the quiz - resets index and shuffles options
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.start = function() {
|
||||||
|
let self = this;
|
||||||
|
self.curIndex = 0;
|
||||||
|
if (self.shuffle) {
|
||||||
|
self.shuffleArray(self.data.options);
|
||||||
|
}
|
||||||
|
self.startTime = Date.now();
|
||||||
|
// Additional logic for tracking can be added here if needed
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* End the game and check answer
|
||||||
|
* @param {string} answer - User's answer (single text or multiple answers separated by |)
|
||||||
|
* @returns {Object} Result object with isCorrect, duration, correctAnswer, and userAnswer
|
||||||
|
*/
|
||||||
|
SenaSDK.prototype.end = function(answer , callback) {
|
||||||
|
let self = this;
|
||||||
|
self.endTime = Date.now();
|
||||||
|
const duration = (self.endTime - self.startTime) / 1000;
|
||||||
|
|
||||||
|
// Parse user answer - split by | for multiple answers
|
||||||
|
const userAnswers = answer.includes('|') ? answer.split('|').map(a => a.trim().toLowerCase()) : [answer.trim().toLowerCase()];
|
||||||
|
|
||||||
|
// Get correct answer(s) from data
|
||||||
|
let correctAnswers = [];
|
||||||
|
if (self.correctAnswer) {
|
||||||
|
// Check if answer is an array (multiple answers) or single answer
|
||||||
|
if (Array.isArray(self.correctAnswer)) {
|
||||||
|
correctAnswers = self.correctAnswer.map(a => {
|
||||||
|
if (typeof a === 'string') return a.toLowerCase();
|
||||||
|
if (a.text) return a.text.toLowerCase();
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
} else if (typeof self.correctAnswer === 'string') {
|
||||||
|
correctAnswers = [self.correctAnswer.toLowerCase()];
|
||||||
|
} else if (self.correctAnswer.text) {
|
||||||
|
correctAnswers = [self.correctAnswer.text.toLowerCase()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if answer is correct
|
||||||
|
let isCorrect = false;
|
||||||
|
if (userAnswers.length === correctAnswers.length) {
|
||||||
|
// For ordered multiple answers
|
||||||
|
isCorrect = userAnswers.every((ans, index) => ans === correctAnswers[index]);
|
||||||
|
} else if (userAnswers.length === 1 && correctAnswers.length === 1) {
|
||||||
|
// For single answer
|
||||||
|
isCorrect = userAnswers[0] === correctAnswers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
isCorrect: isCorrect,
|
||||||
|
duration: duration,
|
||||||
|
correctAnswer: correctAnswers.join(' | '),
|
||||||
|
userAnswer: userAnswers.join(' | ')
|
||||||
|
};
|
||||||
|
// if time spent more than time limit, mark as incorrect
|
||||||
|
if (self.timeLimit > 0 && duration > self.timeLimit) {
|
||||||
|
result.isCorrect = false;
|
||||||
|
}
|
||||||
|
console.log(`Time spent in game: ${duration} seconds`);
|
||||||
|
console.log(`Result: ${isCorrect ? 'CORRECT' : 'INCORRECT'}`);
|
||||||
|
if (callback) callback(result.isCorrect);
|
||||||
|
};
|
||||||
|
|
||||||
|
SenaSDK.prototype.playVoice = function(type) {
|
||||||
|
let self = this;
|
||||||
|
// type: 'question', 'optionA', 'optionB', ...
|
||||||
|
// if type is options, get corresponding option text like option0 -> index 0
|
||||||
|
let textToSpeak = '';
|
||||||
|
if (type.startsWith('option')) {
|
||||||
|
const optionIndex = parseInt(type.slice(6)) - 1;
|
||||||
|
textToSpeak = self.getOptionsValue(optionIndex);
|
||||||
|
} else if (type === 'question') {
|
||||||
|
textToSpeak = self.getQuestionValue();
|
||||||
|
} else if (type === 'request') {
|
||||||
|
textToSpeak = self.getRequestValue();
|
||||||
|
} else if (type === 'hint') {
|
||||||
|
const hintValue = self.getHintValue();
|
||||||
|
if (typeof hintValue === 'string') {
|
||||||
|
textToSpeak = hintValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(textToSpeak == "") return;
|
||||||
|
if (window['audioPlayer']) {
|
||||||
|
window['audioPlayer'].pause();
|
||||||
|
window['audioPlayer'].src = textToSpeak;
|
||||||
|
window['audioPlayer'].play();
|
||||||
|
} else {
|
||||||
|
window['audioPlayer'] = new Audio(textToSpeak);
|
||||||
|
window['audioPlayer'].play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SenaSDK.prototype.helper = {};
|
||||||
|
|
||||||
|
SenaSDK.prototype.helper.CalcObjectPositions = function(n, objectWidth, margin, maxWidth) {
|
||||||
|
let self = this;
|
||||||
|
self.positions = [];
|
||||||
|
const totalWidth = n * objectWidth + (n - 1) * margin;
|
||||||
|
const startX = (maxWidth - totalWidth) / 2;
|
||||||
|
let positions = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
positions.push(startX + i * (objectWidth + margin));
|
||||||
|
}
|
||||||
|
positions.map(pos => pos + objectWidth / 2); // Adjusting to center the objects
|
||||||
|
self.positions = positions;
|
||||||
|
};
|
||||||
|
SenaSDK.prototype.helper.getPosXbyIndex = function(index) {
|
||||||
|
let self = this;
|
||||||
|
if (index < 0 || index >= self.positions.length) {
|
||||||
|
return null; // Return null if index is out of bounds
|
||||||
|
}
|
||||||
|
return self.positions[index];
|
||||||
|
};
|
||||||
|
// Export for different module systems
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SenaSDK;
|
||||||
|
} else if (typeof define === 'function' && define.amd) {
|
||||||
|
define([], function() { return SenaSDK; });
|
||||||
|
} else {
|
||||||
|
window.SenaSDK = SenaSDK;
|
||||||
|
}
|
||||||
403
SQ_Word_Hint-Image/sw.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const OFFLINE_DATA_FILE = "offline.js";
|
||||||
|
const CACHE_NAME_PREFIX = "c2offline";
|
||||||
|
const BROADCASTCHANNEL_NAME = "offline";
|
||||||
|
const CONSOLE_PREFIX = "[SW] ";
|
||||||
|
const LAZYLOAD_KEYNAME = "";
|
||||||
|
|
||||||
|
// Create a BroadcastChannel if supported.
|
||||||
|
const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME));
|
||||||
|
|
||||||
|
//////////////////////////////////////
|
||||||
|
// Utility methods
|
||||||
|
function PostBroadcastMessage(o)
|
||||||
|
{
|
||||||
|
if (!broadcastChannel)
|
||||||
|
return; // not supported
|
||||||
|
|
||||||
|
// Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent.
|
||||||
|
// Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive
|
||||||
|
// in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering,
|
||||||
|
// delay all messages by the same amount.
|
||||||
|
setTimeout(() => broadcastChannel.postMessage(o), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Broadcast(type)
|
||||||
|
{
|
||||||
|
PostBroadcastMessage({
|
||||||
|
"type": type
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function BroadcastDownloadingUpdate(version)
|
||||||
|
{
|
||||||
|
PostBroadcastMessage({
|
||||||
|
"type": "downloading-update",
|
||||||
|
"version": version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function BroadcastUpdateReady(version)
|
||||||
|
{
|
||||||
|
PostBroadcastMessage({
|
||||||
|
"type": "update-ready",
|
||||||
|
"version": version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function IsUrlInLazyLoadList(url, lazyLoadList)
|
||||||
|
{
|
||||||
|
if (!lazyLoadList)
|
||||||
|
return false; // presumably lazy load list failed to load
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const lazyLoadRegex of lazyLoadList)
|
||||||
|
{
|
||||||
|
if (new RegExp(lazyLoadRegex).test(url))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function WriteLazyLoadListToStorage(lazyLoadList)
|
||||||
|
{
|
||||||
|
if (typeof localforage === "undefined")
|
||||||
|
return Promise.resolve(); // bypass if localforage not imported
|
||||||
|
else
|
||||||
|
return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList)
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReadLazyLoadListFromStorage()
|
||||||
|
{
|
||||||
|
if (typeof localforage === "undefined")
|
||||||
|
return Promise.resolve([]); // bypass if localforage not imported
|
||||||
|
else
|
||||||
|
return localforage.getItem(LAZYLOAD_KEYNAME);
|
||||||
|
};
|
||||||
|
|
||||||
|
function GetCacheBaseName()
|
||||||
|
{
|
||||||
|
// Include the scope to avoid name collisions with any other SWs on the same origin.
|
||||||
|
// e.g. "c2offline-https://example.com/foo/" (won't collide with anything under bar/)
|
||||||
|
return CACHE_NAME_PREFIX + "-" + self.registration.scope;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GetCacheVersionName(version)
|
||||||
|
{
|
||||||
|
// Append the version number to the cache name.
|
||||||
|
// e.g. "c2offline-https://example.com/foo/-v2"
|
||||||
|
return GetCacheBaseName() + "-v" + version;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return caches.keys() filtered down to just caches we're interested in (with the right base name).
|
||||||
|
// This filters out caches from unrelated scopes.
|
||||||
|
async function GetAvailableCacheNames()
|
||||||
|
{
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
const cacheBaseName = GetCacheBaseName();
|
||||||
|
return cacheNames.filter(n => n.startsWith(cacheBaseName));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Identify if an update is pending, which is the case when we have 2 or more available caches.
|
||||||
|
// One must be an update that is waiting, since the next navigate that does an upgrade will
|
||||||
|
// delete all the old caches leaving just one currently-in-use cache.
|
||||||
|
async function IsUpdatePending()
|
||||||
|
{
|
||||||
|
const availableCacheNames = await GetAvailableCacheNames();
|
||||||
|
return (availableCacheNames.length >= 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.
|
||||||
|
// This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.
|
||||||
|
async function GetMainPageUrl()
|
||||||
|
{
|
||||||
|
const allClients = await clients.matchAll({
|
||||||
|
includeUncontrolled: true,
|
||||||
|
type: "window"
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of allClients)
|
||||||
|
{
|
||||||
|
// Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html
|
||||||
|
let url = c.url;
|
||||||
|
if (url.startsWith(self.registration.scope))
|
||||||
|
url = url.substring(self.registration.scope.length);
|
||||||
|
|
||||||
|
if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that
|
||||||
|
{
|
||||||
|
// If the URL is solely a search string, prefix it with / to ensure it caches correctly.
|
||||||
|
// e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.
|
||||||
|
if (url.startsWith("?"))
|
||||||
|
url = "/" + url;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""; // no main page URL could be identified
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hack to fetch optionally bypassing HTTP cache until fetch cache options are supported in Chrome (crbug.com/453190)
|
||||||
|
function fetchWithBypass(request, bypassCache)
|
||||||
|
{
|
||||||
|
if (typeof request === "string")
|
||||||
|
request = new Request(request);
|
||||||
|
|
||||||
|
if (bypassCache)
|
||||||
|
{
|
||||||
|
// bypass enabled: add a random search parameter to avoid getting a stale HTTP cache result
|
||||||
|
const url = new URL(request.url);
|
||||||
|
url.search += Math.floor(Math.random() * 1000000);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
headers: request.headers,
|
||||||
|
mode: request.mode,
|
||||||
|
credentials: request.credentials,
|
||||||
|
redirect: request.redirect,
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// bypass disabled: perform normal fetch which is allowed to return from HTTP cache
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)
|
||||||
|
// and can optionally cache-bypass with fetchWithBypass in every request
|
||||||
|
async function CreateCacheFromFileList(cacheName, fileList, bypassCache)
|
||||||
|
{
|
||||||
|
// Kick off all requests and wait for them all to complete
|
||||||
|
const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache)));
|
||||||
|
|
||||||
|
// Check if any request failed. If so don't move on to opening the cache.
|
||||||
|
// This makes sure we only open a cache if all requests succeeded.
|
||||||
|
let allOk = true;
|
||||||
|
|
||||||
|
for (const response of responses)
|
||||||
|
{
|
||||||
|
if (!response.ok)
|
||||||
|
{
|
||||||
|
allOk = false;
|
||||||
|
console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allOk)
|
||||||
|
throw new Error("not all resources were fetched successfully");
|
||||||
|
|
||||||
|
// Can now assume all responses are OK. Open a cache and write all responses there.
|
||||||
|
// TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.
|
||||||
|
// This needs either new transactional features in the spec, or at the very least a way to rename a cache
|
||||||
|
// (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.all(responses.map(
|
||||||
|
(response, i) => cache.put(fileList[i], response)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
// Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,
|
||||||
|
// clean up the cache to try to avoid leaving behind an incomplete cache.
|
||||||
|
console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);
|
||||||
|
caches.delete(cacheName);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function UpdateCheck(isFirst)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Always bypass cache when requesting offline.js to make sure we find out about new versions.
|
||||||
|
const response = await fetchWithBypass(OFFLINE_DATA_FILE, true);
|
||||||
|
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const version = data.version;
|
||||||
|
const fileList = data.fileList;
|
||||||
|
const lazyLoadList = data.lazyLoad;
|
||||||
|
const currentCacheName = GetCacheVersionName(version);
|
||||||
|
|
||||||
|
const cacheExists = await caches.has(currentCacheName);
|
||||||
|
|
||||||
|
// Don't recache if there is already a cache that exists for this version. Assume it is complete.
|
||||||
|
if (cacheExists)
|
||||||
|
{
|
||||||
|
// Log whether we are up-to-date or pending an update.
|
||||||
|
const isUpdatePending = await IsUpdatePending();
|
||||||
|
if (isUpdatePending)
|
||||||
|
{
|
||||||
|
console.log(CONSOLE_PREFIX + "Update pending");
|
||||||
|
Broadcast("update-pending");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
console.log(CONSOLE_PREFIX + "Up to date");
|
||||||
|
Broadcast("up-to-date");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.
|
||||||
|
const mainPageUrl = await GetMainPageUrl();
|
||||||
|
|
||||||
|
// Prepend the main page URL to the file list if we found one and it is not already in the list.
|
||||||
|
// Also make sure we request the base / which should serve the main page.
|
||||||
|
fileList.unshift("./");
|
||||||
|
|
||||||
|
if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1)
|
||||||
|
fileList.unshift(mainPageUrl);
|
||||||
|
|
||||||
|
console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");
|
||||||
|
|
||||||
|
if (isFirst)
|
||||||
|
Broadcast("downloading");
|
||||||
|
else
|
||||||
|
BroadcastDownloadingUpdate(version);
|
||||||
|
|
||||||
|
// Note we don't bypass the cache on the first update check. This is because SW installation and the following
|
||||||
|
// update check caching will race with the normal page load requests. For any normal loading fetches that have already
|
||||||
|
// completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that
|
||||||
|
// forces a second network request to be issued when a response from the browser HTTP cache would be fine.
|
||||||
|
if (lazyLoadList)
|
||||||
|
await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage#
|
||||||
|
|
||||||
|
await CreateCacheFromFileList(currentCacheName, fileList, !isFirst);
|
||||||
|
const isUpdatePending = await IsUpdatePending();
|
||||||
|
|
||||||
|
if (isUpdatePending)
|
||||||
|
{
|
||||||
|
console.log(CONSOLE_PREFIX + "All resources saved, update ready");
|
||||||
|
BroadcastUpdateReady(version);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");
|
||||||
|
Broadcast("offline-ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
// Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.
|
||||||
|
console.warn(CONSOLE_PREFIX + "Update check failed: ", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.addEventListener("install", event =>
|
||||||
|
{
|
||||||
|
// On install kick off an update check to cache files on first use.
|
||||||
|
// If it fails we can still complete the install event and leave the SW running, we'll just
|
||||||
|
// retry on the next navigate.
|
||||||
|
event.waitUntil(
|
||||||
|
UpdateCheck(true) // first update
|
||||||
|
.catch(() => null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function GetCacheNameToUse(availableCacheNames, doUpdateCheck)
|
||||||
|
{
|
||||||
|
// Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache
|
||||||
|
// is created and filled due to an update check while the page is running, we keep returning resources
|
||||||
|
// from the original (oldest) cache only.
|
||||||
|
if (availableCacheNames.length === 1 || !doUpdateCheck)
|
||||||
|
return availableCacheNames[0];
|
||||||
|
|
||||||
|
// We are making a navigate request with more than one cache available. Check if we can expire any old ones.
|
||||||
|
const allClients = await clients.matchAll();
|
||||||
|
|
||||||
|
// If there are other clients open, don't expire anything yet. We don't want to delete any caches they
|
||||||
|
// might be using, which could cause mixed-version responses.
|
||||||
|
if (allClients.length > 1)
|
||||||
|
return availableCacheNames[0];
|
||||||
|
|
||||||
|
// Identify newest cache to use. Delete all the others.
|
||||||
|
const latestCacheName = availableCacheNames[availableCacheNames.length - 1];
|
||||||
|
console.log(CONSOLE_PREFIX + "Updating to new version");
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
availableCacheNames.slice(0, -1)
|
||||||
|
.map(c => caches.delete(c))
|
||||||
|
);
|
||||||
|
|
||||||
|
return latestCacheName;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function HandleFetch(event, doUpdateCheck)
|
||||||
|
{
|
||||||
|
const availableCacheNames = await GetAvailableCacheNames();
|
||||||
|
|
||||||
|
// No caches available: go to network
|
||||||
|
if (!availableCacheNames.length)
|
||||||
|
return fetch(event.request);
|
||||||
|
|
||||||
|
const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck);
|
||||||
|
const cache = await caches.open(useCacheName);
|
||||||
|
const cachedResponse = await cache.match(event.request);
|
||||||
|
|
||||||
|
if (cachedResponse)
|
||||||
|
return cachedResponse; // use cached response
|
||||||
|
|
||||||
|
// We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list
|
||||||
|
// from storage simultaneously.
|
||||||
|
const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]);
|
||||||
|
const fetchResponse = result[0];
|
||||||
|
const lazyLoadList = result[1];
|
||||||
|
|
||||||
|
if (IsUrlInLazyLoadList(event.request.url, lazyLoadList))
|
||||||
|
{
|
||||||
|
// Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly
|
||||||
|
// likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error
|
||||||
|
// we continue to return the response from the fetch.
|
||||||
|
try {
|
||||||
|
// Note clone response since we also respond with it
|
||||||
|
await cache.put(event.request, fetchResponse.clone());
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.addEventListener("fetch", event =>
|
||||||
|
{
|
||||||
|
/** NOTE (iain)
|
||||||
|
* This check is to prevent a bug with XMLHttpRequest where if its
|
||||||
|
* proxied with "FetchEvent.prototype.respondWith" no upload progress
|
||||||
|
* events are triggered. By returning we allow the default action to
|
||||||
|
* occur instead. Currently all cross-origin requests fall back to default.
|
||||||
|
*/
|
||||||
|
if (new URL(event.request.url).origin !== location.origin)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check for an update on navigate requests
|
||||||
|
const doUpdateCheck = (event.request.mode === "navigate");
|
||||||
|
|
||||||
|
const responsePromise = HandleFetch(event, doUpdateCheck);
|
||||||
|
|
||||||
|
if (doUpdateCheck)
|
||||||
|
{
|
||||||
|
// allow the main request to complete, then check for updates
|
||||||
|
event.waitUntil(
|
||||||
|
responsePromise
|
||||||
|
.then(() => UpdateCheck(false)) // not first check
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(responsePromise);
|
||||||
|
});
|
||||||
733
SQ_Word_Hint-Image/tdv_sdk.js
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
/**
|
||||||
|
* TDV SDK v8.2 - SENA PLUGIN SYNC VERSION
|
||||||
|
* - Sync dữ liệu từ SenaAI Construct 2 Plugin (window.SenaTrigger.sdk)
|
||||||
|
* - Hoặc tự load từ: https://senaai.tech/sample/{GameCode}.json
|
||||||
|
* - Hỗ trợ Quiz dạng: Câu hỏi Text, Đáp án Text/Image
|
||||||
|
**/
|
||||||
|
|
||||||
|
var tdv_sdk = {
|
||||||
|
game_code: 'G1400S1T30',
|
||||||
|
activeSdk: null,
|
||||||
|
serverDataLoaded: false,
|
||||||
|
gameStartTime: null,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
totalScore: 0,
|
||||||
|
timeLimit: 0,
|
||||||
|
_timerStarted: false,
|
||||||
|
_lastLogTime: -1,
|
||||||
|
_isPaused: false,
|
||||||
|
_pausedElapsed: 0,
|
||||||
|
|
||||||
|
// ==================== SYNC FROM SENA PLUGIN ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync data từ SenaAI Plugin
|
||||||
|
* Gọi hàm này sau khi SenaAI.Load hoàn tất (On LOAD Complete)
|
||||||
|
*/
|
||||||
|
syncFromPlugin: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Lấy SDK instance từ SenaAI Plugin
|
||||||
|
if (window.SenaTrigger && window.SenaTrigger.sdk) {
|
||||||
|
var pluginSdk = window.SenaTrigger.sdk;
|
||||||
|
|
||||||
|
// Nếu plugin chưa có data, trả về false để SDK tự load
|
||||||
|
if (!pluginSdk.data) {
|
||||||
|
console.warn('⚠️ TDV SDK: Plugin has no data yet, will fallback to self-load');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.activeSdk = {
|
||||||
|
data: pluginSdk.data,
|
||||||
|
correctAnswer: pluginSdk.correctAnswer,
|
||||||
|
shuffle: pluginSdk.shuffle,
|
||||||
|
gameCode: pluginSdk.gameCode,
|
||||||
|
startTime: pluginSdk.startTime,
|
||||||
|
timeLimit: pluginSdk.timeLimit
|
||||||
|
};
|
||||||
|
|
||||||
|
self.game_code = pluginSdk.gameCode || self.game_code;
|
||||||
|
self.timeLimit = pluginSdk.timeLimit || 0;
|
||||||
|
self._parseGameCode(); // Fallback parse if plugin doesn't have it
|
||||||
|
|
||||||
|
// SyncStartTime: Chỉ đồng bộ nếu tdv_sdk chưa bắt đầu đếm ngược
|
||||||
|
if (pluginSdk.startTime > 0 && !this._timerStarted) {
|
||||||
|
this.gameStartTime = pluginSdk.startTime;
|
||||||
|
this._timerStarted = true;
|
||||||
|
console.log('🔗 Timer synced from plugin:', this.gameStartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.serverDataLoaded = true;
|
||||||
|
console.log('✅ TDV SDK: Synced from SenaAI Plugin (StartTime:', self.gameStartTime, ')');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('⚠️ TDV SDK: SenaAI Plugin not found');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize - tự động sync từ plugin nếu có
|
||||||
|
*/
|
||||||
|
init: function (config) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
if (config && config.gameCode) {
|
||||||
|
self.game_code = config.gameCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override từ URL param LID
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
var LID = urlParams.get('LID');
|
||||||
|
if (LID) {
|
||||||
|
self.game_code = LID;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎮 TDV SDK v8.2 - SenaAI Plugin Sync');
|
||||||
|
console.log('📦 Game Code:', self.game_code);
|
||||||
|
|
||||||
|
// Tự động sync nếu plugin đã load
|
||||||
|
self.syncFromPlugin();
|
||||||
|
self._parseGameCode();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Parse game code để lấy cấu hình timeLimit, shuffle
|
||||||
|
*/
|
||||||
|
_parseGameCode: function () {
|
||||||
|
var self = this;
|
||||||
|
var regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
|
||||||
|
var match = String(self.game_code).match(regex);
|
||||||
|
if (match) {
|
||||||
|
var timeStr = match[6] !== undefined ? match[6] : '0';
|
||||||
|
self.timeLimit = parseInt(timeStr, 10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data - ưu tiên sync từ plugin, nếu không thì tự load
|
||||||
|
*/
|
||||||
|
load: function (callback) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// Thử sync từ plugin trước
|
||||||
|
if (self.syncFromPlugin()) {
|
||||||
|
if (callback) callback(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu không có plugin, tự load từ server
|
||||||
|
var url = 'https://senaai.tech/sample/' + self.game_code + '.json';
|
||||||
|
console.log('📡 TDV SDK: Self-loading from:', url);
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (json) {
|
||||||
|
console.log('✅ TDV SDK: Data loaded:', json);
|
||||||
|
|
||||||
|
self.activeSdk = {
|
||||||
|
data: json.data,
|
||||||
|
correctAnswer: json.answer,
|
||||||
|
shuffle: false
|
||||||
|
};
|
||||||
|
|
||||||
|
self.serverDataLoaded = true;
|
||||||
|
// self.gameStartTime = Date.now(); // REMOVED: Chỉ bắt đầu khi Start
|
||||||
|
if (callback) callback(true);
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
console.error('❌ TDV SDK: Load Error:', error);
|
||||||
|
self.serverDataLoaded = false;
|
||||||
|
if (callback) callback(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start game
|
||||||
|
*/
|
||||||
|
start: function () {
|
||||||
|
// Sync lại từ plugin mỗi khi start (đảm bảo data mới nhất)
|
||||||
|
this.syncFromPlugin();
|
||||||
|
this._parseGameCode();
|
||||||
|
this.gameStartTime = Date.now();
|
||||||
|
this._timerStarted = true;
|
||||||
|
this._isPaused = false;
|
||||||
|
this._pausedElapsed = 0;
|
||||||
|
this.currentQuestionIndex = 0;
|
||||||
|
console.log('🎮 Game Started! Timer set to:', this.timeLimit);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== DATA GETTERS - Tự động sync nếu chưa có data ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy SDK data, sync từ plugin nếu chưa có
|
||||||
|
*/
|
||||||
|
_getSdk: function () {
|
||||||
|
if (!this.activeSdk || !this.activeSdk.data) {
|
||||||
|
this.syncFromPlugin();
|
||||||
|
}
|
||||||
|
return this.activeSdk;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy instruction/request text
|
||||||
|
*/
|
||||||
|
getInstructions: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return "";
|
||||||
|
return sdk.data.request || sdk.data.question || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy request text
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getRequest()")
|
||||||
|
*/
|
||||||
|
getRequest: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return "";
|
||||||
|
return sdk.data.request || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy question text
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getQuestion()")
|
||||||
|
*/
|
||||||
|
getQuestion: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return "";
|
||||||
|
var q = String(sdk.data.question || "").trim();
|
||||||
|
|
||||||
|
// Nếu question là một URL (bắt đầu bằng http hoặc là link ảnh/âm thanh), trả về rỗng để tránh hiện text
|
||||||
|
if (q.toLowerCase().startsWith('http') || this._isAudioUrl(q) || this._isImageUrl(q)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return q;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy loại câu hỏi: 'text', 'image', 'audio'
|
||||||
|
*/
|
||||||
|
getQuestionType: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return 'text';
|
||||||
|
|
||||||
|
var q = String(sdk.data.question || "");
|
||||||
|
var isUrl = q.toLowerCase().startsWith('http');
|
||||||
|
|
||||||
|
// Check audio trước
|
||||||
|
if (sdk.data.audio || (isUrl && this._isAudioUrl(q))) return 'audio';
|
||||||
|
|
||||||
|
// Check image
|
||||||
|
if (sdk.data.image || sdk.data.image_url || (isUrl && this._isImageUrl(q))) return 'image';
|
||||||
|
|
||||||
|
// Fallback theo game code (số thứ 3)
|
||||||
|
if (this.game_code && this.game_code.length >= 4) {
|
||||||
|
var qTypeChar = this.game_code.charAt(3);
|
||||||
|
if (qTypeChar === '1') return 'image';
|
||||||
|
if (qTypeChar === '2') return 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Kiểm tra chuỗi có phải URL âm thanh không
|
||||||
|
*/
|
||||||
|
_isAudioUrl: function (url) {
|
||||||
|
var str = String(url).toLowerCase();
|
||||||
|
if (!str.startsWith('http')) return false;
|
||||||
|
var exts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac'];
|
||||||
|
for (var i = 0; i < exts.length; i++) {
|
||||||
|
if (str.endsWith(exts[i])) return true;
|
||||||
|
}
|
||||||
|
return str.includes('/audio/') || str.includes('audio.');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Kiểm tra chuỗi có phải URL hình ảnh không
|
||||||
|
*/
|
||||||
|
_isImageUrl: function (url) {
|
||||||
|
var str = String(url).toLowerCase();
|
||||||
|
if (!str.startsWith('http')) return false;
|
||||||
|
var exts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
|
||||||
|
for (var i = 0; i < exts.length; i++) {
|
||||||
|
if (str.endsWith(exts[i])) return true;
|
||||||
|
}
|
||||||
|
return str.includes('/img/') || str.includes('/image/') || str.includes('image.');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra có hình ảnh câu hỏi không
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.hasImage()") = 1
|
||||||
|
*/
|
||||||
|
hasImage: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return 0;
|
||||||
|
var url = sdk.data.image_url || sdk.data.image || "";
|
||||||
|
if (url && String(url).toLowerCase().startsWith('http')) return 1;
|
||||||
|
|
||||||
|
// Nếu không có field image riêng, check question có phải là link ảnh không
|
||||||
|
var q = sdk.data.question || "";
|
||||||
|
return this._isImageUrl(q) ? 1 : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy URL hình ảnh câu hỏi
|
||||||
|
*/
|
||||||
|
getImageUrl: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return "";
|
||||||
|
var url = sdk.data.image_url || sdk.data.image || "";
|
||||||
|
|
||||||
|
if (!url && this._isImageUrl(sdk.data.question)) {
|
||||||
|
url = sdk.data.question;
|
||||||
|
}
|
||||||
|
return this.getCorsUrl(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra có audio câu hỏi không
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.hasAudio()") = 1
|
||||||
|
*/
|
||||||
|
hasAudio: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return 0;
|
||||||
|
if (sdk.data.audio && sdk.data.audio.length > 0) return 1;
|
||||||
|
|
||||||
|
// Check nếu question là link audio
|
||||||
|
return this._isAudioUrl(sdk.data.question) ? 1 : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy URL audio câu hỏi
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getAudioUrl()")
|
||||||
|
*/
|
||||||
|
getAudioUrl: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data) return "";
|
||||||
|
var url = sdk.data.audio || "";
|
||||||
|
|
||||||
|
if (!url && this._isAudioUrl(sdk.data.question)) {
|
||||||
|
url = sdk.data.question;
|
||||||
|
}
|
||||||
|
return this.getCorsUrl(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== OPTIONS GETTERS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy số lượng options
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getOptionsCount()")
|
||||||
|
*/
|
||||||
|
getOptionsCount: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data || !sdk.data.options) return 0;
|
||||||
|
return sdk.data.options.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy giá trị gốc của option (Object hoặc String)
|
||||||
|
*/
|
||||||
|
_getRawOptionValue: function (index) {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.data || !sdk.data.options) return null;
|
||||||
|
return sdk.data.options[index];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Parse JSON string if needed
|
||||||
|
*/
|
||||||
|
_parseData: function (val) {
|
||||||
|
if (typeof val === 'string' && val.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
var obj = JSON.parse(val);
|
||||||
|
return obj.text || val;
|
||||||
|
} catch (e) { return val; }
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy text của option theo index
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getAnswerByIndex(0)")
|
||||||
|
*/
|
||||||
|
getAnswerByIndex: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
if (!opt) return "";
|
||||||
|
|
||||||
|
// Trường hợp là Object {text: "...", image: "..."}
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
return opt.text || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trường hợp là chuỗi (có thể là JSON hoặc URL)
|
||||||
|
var text = this._parseData(String(opt));
|
||||||
|
|
||||||
|
// Nếu text là một URL (bắt đầu bằng http), trả về rỗng để tránh hiện link trên nút
|
||||||
|
if (typeof text === 'string' && text.toLowerCase().startsWith('http')) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias - getOptionText
|
||||||
|
*/
|
||||||
|
getOptionText: function (index) {
|
||||||
|
return this.getAnswerByIndex(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra option có hình ảnh không
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.hasOptionImage(0)") = 1
|
||||||
|
*/
|
||||||
|
hasOptionImage: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
if (!opt) return 0;
|
||||||
|
|
||||||
|
// Trường hợp là Object
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
var url = opt.image || opt.image_url || "";
|
||||||
|
return (url && url.length > 0) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trường hợp là chuỗi (check link ảnh)
|
||||||
|
return this._isImageUrl(String(opt)) ? 1 : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy URL hình ảnh của option
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getOptionImageUrl(0)")
|
||||||
|
*/
|
||||||
|
getOptionImageUrl: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
if (!opt) return "";
|
||||||
|
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
var url = opt.image || opt.image_url || "";
|
||||||
|
return this.getCorsUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu là link ảnh trực tiếp
|
||||||
|
var str = String(opt);
|
||||||
|
if (this._isImageUrl(str)) {
|
||||||
|
return this.getCorsUrl(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra option có audio không
|
||||||
|
* Trả về 1 nếu là object có property audio HOẶC là chuỗi URL dẫn đến file âm thanh
|
||||||
|
*/
|
||||||
|
hasOptionAudio: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
if (!opt) return 0;
|
||||||
|
|
||||||
|
// Trường hợp là Object
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
return (opt.audio && opt.audio.length > 0) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trường hợp là chuỗi (kiểm tra xem có phải link audio không)
|
||||||
|
var str = String(opt).toLowerCase();
|
||||||
|
if (str.startsWith('http')) {
|
||||||
|
// Check các định dạng âm thanh phổ biến
|
||||||
|
var extensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac'];
|
||||||
|
for (var i = 0; i < extensions.length; i++) {
|
||||||
|
if (str.endsWith(extensions[i])) return 1;
|
||||||
|
}
|
||||||
|
if (str.includes('/audio/') || str.includes('audio.')) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy URL audio của option
|
||||||
|
*/
|
||||||
|
getOptionAudio: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
if (!opt) return "";
|
||||||
|
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
return this.getCorsUrl(opt.audio || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu là chuỗi bắt đầu bằng http, coi như đó là URL audio trực tiếp
|
||||||
|
var str = String(opt);
|
||||||
|
if (str.toLowerCase().startsWith('http')) {
|
||||||
|
return this.getCorsUrl(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== ANSWER CHECKING (Trigger Plugin Events) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy đáp án đúng
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getCorrectResultText()")
|
||||||
|
*/
|
||||||
|
getCorrectResultText: function () {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
if (!sdk || !sdk.correctAnswer) return "";
|
||||||
|
return String(sdk.correctAnswer);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra đáp án - QUAN TRỌNG cho SenaAI Plugin
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.checkAnswer('apple')")
|
||||||
|
* Returns: 1 (đúng) hoặc 0 (sai)
|
||||||
|
*
|
||||||
|
* Plugin sẽ trigger:
|
||||||
|
* - "On Correct Answer" nếu đúng
|
||||||
|
* - "On Wrong Answer" nếu sai
|
||||||
|
*/
|
||||||
|
checkAnswer: function (userAnswer) {
|
||||||
|
var self = this;
|
||||||
|
var isCorrect = 0;
|
||||||
|
|
||||||
|
// Ưu tiên dùng hàm end() của Plugin SDK chính để tính toán duration/score
|
||||||
|
if (window.SenaTrigger && window.SenaTrigger.sdk) {
|
||||||
|
console.log('🏁 Calling official sena_sdk.end()...');
|
||||||
|
window.SenaTrigger.sdk.end(userAnswer, function (result) {
|
||||||
|
isCorrect = result ? 1 : 0;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback nếu không có plugin (logic cũ)
|
||||||
|
var correct = this.getCorrectResultText().toLowerCase().trim();
|
||||||
|
var user = String(userAnswer).toLowerCase().trim();
|
||||||
|
|
||||||
|
if (user.includes('corsproxy.io/?')) {
|
||||||
|
try {
|
||||||
|
var decoded = decodeURIComponent(user.split('corsproxy.io/?')[1]);
|
||||||
|
if (decoded) user = decoded.toLowerCase().trim();
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
isCorrect = (user === correct) ? 1 : 0;
|
||||||
|
if (!isCorrect && (user.startsWith('http') || correct.startsWith('http'))) {
|
||||||
|
var getFileName = function (url) {
|
||||||
|
var parts = url.split('/');
|
||||||
|
return parts[parts.length - 1].split('?')[0];
|
||||||
|
};
|
||||||
|
if (getFileName(user) === getFileName(correct)) isCorrect = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đồng bộ kết quả ra Bridge cho Construct 2
|
||||||
|
window.tdv_bridge_result = isCorrect;
|
||||||
|
if (isCorrect) this.totalScore++;
|
||||||
|
|
||||||
|
console.log('📝 Result Bridge:', isCorrect === 1 ? '✅ CORRECT' : '❌ WRONG');
|
||||||
|
return isCorrect;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra đáp án theo index của option đã chọn
|
||||||
|
* Đã cải tiến để lấy đúng giá trị (text hoặc URL) để so sánh
|
||||||
|
*/
|
||||||
|
checkAnswerByIndex: function (index) {
|
||||||
|
var opt = this._getRawOptionValue(index);
|
||||||
|
var val = "";
|
||||||
|
if (typeof opt === 'object') {
|
||||||
|
val = opt.text || "";
|
||||||
|
} else {
|
||||||
|
val = String(opt);
|
||||||
|
}
|
||||||
|
return this.checkAnswer(val);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit đáp án - alias cho checkAnswer
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.play('apple')")
|
||||||
|
*/
|
||||||
|
play: function (userAnswer) {
|
||||||
|
return this.checkAnswer(userAnswer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== AUDIO PLAYBACK ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phát audio câu hỏi
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.playQuestionAudio()")
|
||||||
|
*/
|
||||||
|
playQuestionAudio: function () {
|
||||||
|
var url = this.getAudioUrl();
|
||||||
|
if (url) {
|
||||||
|
console.log('🔊 Playing question audio');
|
||||||
|
new Audio(url).play().catch(function (e) { console.error(e); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phát audio option
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.playOptionAudio(0)")
|
||||||
|
*/
|
||||||
|
playOptionAudio: function (index) {
|
||||||
|
var url = this.getOptionAudio(index);
|
||||||
|
if (url) {
|
||||||
|
console.log('🔊 Playing option', index, 'audio');
|
||||||
|
new Audio(url).play().catch(function (e) { console.error(e); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phát audio từ URL
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.playSpecificAudio('url')")
|
||||||
|
*/
|
||||||
|
playSpecificAudio: function (url) {
|
||||||
|
if (url && url !== "" && url !== "NaN") {
|
||||||
|
new Audio(url).play().catch(function (e) { console.error(e); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== SCORE & GAME STATE ====================
|
||||||
|
|
||||||
|
getCurrentScore: function () { return this.totalScore; },
|
||||||
|
getScore: function () { return this.totalScore; },
|
||||||
|
getCurrentNumber: function () { return this.currentQuestionIndex + 1; },
|
||||||
|
getTotalQuestions: function () { return 1; },
|
||||||
|
/**
|
||||||
|
* Tính toán font size linh hoạt dựa trên độ dài văn bản
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getFontSizeForText('văn bản', 36, 20)")
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Chèn thêm Proxy để vượt rào CORS cho các link ảnh/audio từ server lạ
|
||||||
|
* Sử dụng: Browser.ExecJS("tdv_sdk.getCorsUrl('link_anh')")
|
||||||
|
*/
|
||||||
|
getCorsUrl: function (url) {
|
||||||
|
url = String(url || "");
|
||||||
|
if (!url || !url.startsWith('http')) return url;
|
||||||
|
|
||||||
|
// Nếu đã là link từ senaai.tech thì không cần proxy
|
||||||
|
if (url.includes('senaai.tech')) return url;
|
||||||
|
|
||||||
|
// Sử dụng một trong các public proxy (có thể thay đổi nếu proxy này die)
|
||||||
|
return "https://corsproxy.io/?" + encodeURIComponent(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFontSizeForText: function (text, defaultSize, minSize) {
|
||||||
|
text = String(text || "");
|
||||||
|
defaultSize = Number(defaultSize) || 36;
|
||||||
|
minSize = Number(minSize) || 20;
|
||||||
|
|
||||||
|
var len = text.length;
|
||||||
|
if (len <= 12) return defaultSize;
|
||||||
|
if (len >= 40) return minSize;
|
||||||
|
|
||||||
|
// Giảm dần font size tuyến tính dựa trên độ dài
|
||||||
|
var ratio = (len - 12) / (40 - 12);
|
||||||
|
var size = defaultSize - (ratio * (defaultSize - minSize));
|
||||||
|
return Math.floor(size);
|
||||||
|
},
|
||||||
|
|
||||||
|
getFontSizeForOption: function (index, defaultSize, minSize) {
|
||||||
|
var text = this.getAnswerByIndex(index);
|
||||||
|
return this.getFontSizeForText(text, defaultSize, minSize);
|
||||||
|
},
|
||||||
|
getRemainingTime: function () {
|
||||||
|
// Ưu tiên dùng trực tiếp từ Core SDK nếu có
|
||||||
|
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.getRemainingTime === 'function') {
|
||||||
|
return window.SenaTrigger.sdk.getRemainingTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback cho tdv_sdk tự đếm (nếu plugin chưa load xong hoặc plugin cũ)
|
||||||
|
var limit = this.timeLimit > 0 ? this.timeLimit : 30;
|
||||||
|
|
||||||
|
// Chỉ sync từ plugin nếu chưa bắt đầu
|
||||||
|
if (!this._timerStarted) {
|
||||||
|
this.syncFromPlugin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu vẫn chưa có mốc thời gian, tự khởi tạo (chỉ làm 1 lần)
|
||||||
|
if (!this._timerStarted) {
|
||||||
|
this.gameStartTime = Date.now();
|
||||||
|
this._timerStarted = true;
|
||||||
|
this._isPaused = false;
|
||||||
|
this._pausedElapsed = 0;
|
||||||
|
console.log('⏱️ SDK Auto-start timer:', limit, 'seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsed = 0;
|
||||||
|
if (this._isPaused) {
|
||||||
|
elapsed = this._pausedElapsed;
|
||||||
|
} else {
|
||||||
|
elapsed = (Date.now() - this.gameStartTime) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = limit - elapsed;
|
||||||
|
var finalTime = Math.max(0, Math.floor(remaining));
|
||||||
|
|
||||||
|
// Log trạng thái để debug
|
||||||
|
if (finalTime % 5 === 0 && finalTime !== this._lastLogTime) {
|
||||||
|
console.log('⏳ Time Left:', finalTime, (this._isPaused ? '[PAUSED]' : ''), '(Elapsed:', Math.floor(elapsed), ')');
|
||||||
|
this._lastLogTime = finalTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalTime;
|
||||||
|
},
|
||||||
|
isDataLoaded: function () { return this.serverDataLoaded; },
|
||||||
|
|
||||||
|
// ==================== LEGACY COMPATIBILITY ====================
|
||||||
|
|
||||||
|
getAttr: function (attr) {
|
||||||
|
var sdk = this._getSdk();
|
||||||
|
return (sdk && sdk.data) ? (sdk.data[attr] || "") : "";
|
||||||
|
},
|
||||||
|
|
||||||
|
recordResult: function (res) { window.tdv_bridge_result = res ? 1 : 0; },
|
||||||
|
finish: function () { console.log('🏁 Game Finished!'); },
|
||||||
|
resumeTime: function () {
|
||||||
|
if (!this._isPaused) return;
|
||||||
|
|
||||||
|
// Cập nhật lại gameStartTime để bù đắp cho khoảng thời gian đã trôi qua
|
||||||
|
this.gameStartTime = Date.now() - (this._pausedElapsed * 1000);
|
||||||
|
this._isPaused = false;
|
||||||
|
|
||||||
|
// Đồng bộ với Core SDK mới nhất
|
||||||
|
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.resume === 'function') {
|
||||||
|
window.SenaTrigger.sdk.resume();
|
||||||
|
} else if (window.SenaTrigger && window.SenaTrigger.sdk) {
|
||||||
|
window.SenaTrigger.sdk.startTime = this.gameStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('▶️ Timer Resumed via tdv_sdk');
|
||||||
|
},
|
||||||
|
pauseTime: function () {
|
||||||
|
if (this._isPaused || !this._timerStarted) return;
|
||||||
|
|
||||||
|
this._pausedElapsed = (Date.now() - this.gameStartTime) / 1000;
|
||||||
|
this._isPaused = true;
|
||||||
|
|
||||||
|
// Đồng bộ với Core SDK mới nhất
|
||||||
|
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.pause === 'function') {
|
||||||
|
window.SenaTrigger.sdk.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⏸️ Timer Paused at:', Math.floor(this._pausedElapsed), 's via tdv_sdk');
|
||||||
|
},
|
||||||
|
stopTime: function () { this.pauseTime(); },
|
||||||
|
submitAllResults: function () { console.log('📤 Submitting results...'); },
|
||||||
|
forceFinishGame: function () {
|
||||||
|
console.log('🚫 Force Finish Game (Time Up)');
|
||||||
|
// Khi hết giờ, nộp một đáp án rỗng "" để tính là SAI và kết thúc game
|
||||||
|
this.checkAnswer("");
|
||||||
|
this.finish();
|
||||||
|
},
|
||||||
|
nextQuestion: function () { this.currentQuestionIndex++; },
|
||||||
|
leaderboard: function () { },
|
||||||
|
result: function () { }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-init
|
||||||
|
window.tdv_sdk = tdv_sdk;
|
||||||
|
tdv_sdk.init();
|
||||||
|
console.log('✅ TDV SDK v8.2 Ready - Use with SenaAI Plugin');
|
||||||