90
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying to /var/www/html/games"
|
||||||
|
echo "📁 Current directory: $(pwd)"
|
||||||
|
echo "📁 GITHUB_WORKSPACE: $GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
# Read game info from readme.md
|
||||||
|
if [ -f "$GITHUB_WORKSPACE/readme.md" ]; then
|
||||||
|
GAME_TITLE=$(sed -n '1p' "$GITHUB_WORKSPACE/readme.md")
|
||||||
|
GAME_DESC=$(sed -n '2p' "$GITHUB_WORKSPACE/readme.md")
|
||||||
|
GAME_TYPE=$(sed -n '3p' "$GITHUB_WORKSPACE/readme.md")
|
||||||
|
else
|
||||||
|
GAME_TITLE="Untitled Game"
|
||||||
|
GAME_DESC="No description"
|
||||||
|
GAME_TYPE="quiz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create base directory
|
||||||
|
mkdir -p /var/www/html/games/
|
||||||
|
|
||||||
|
# Find and deploy all game folders (exclude source, .git, .gitea, etc.)
|
||||||
|
DEPLOYED_FOLDERS=""
|
||||||
|
DEPLOYED_URLS=""
|
||||||
|
for folder in $GITHUB_WORKSPACE/*/; do
|
||||||
|
folder_name=$(basename "$folder")
|
||||||
|
|
||||||
|
# Skip excluded folders
|
||||||
|
if [[ "$folder_name" == "source" ]] || \
|
||||||
|
[[ "$folder_name" == ".git" ]] || \
|
||||||
|
[[ "$folder_name" == ".gitea" ]] || \
|
||||||
|
[[ "$folder_name" == "node_modules" ]] || \
|
||||||
|
[[ "$folder_name" == "logs" ]] || \
|
||||||
|
[[ "$folder_name" == "uploads" ]]; then
|
||||||
|
echo "⏭️ Skipping: $folder_name"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy the folder
|
||||||
|
echo "📦 Deploying: $folder_name"
|
||||||
|
rsync -av --delete "$folder" "/var/www/html/games/$folder_name/"
|
||||||
|
DEPLOYED_FOLDERS="$DEPLOYED_FOLDERS $folder_name"
|
||||||
|
GAME_URL="https://senaai.tech/games/$folder_name/"
|
||||||
|
DEPLOYED_URLS="$DEPLOYED_URLS\n 🔗 $GAME_URL"
|
||||||
|
|
||||||
|
# Get thumbnail (first image found or default)
|
||||||
|
THUMBNAIL=$(find "$folder" -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" \) 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -z "$THUMBNAIL" ]; then
|
||||||
|
THUMBNAIL="https://senaai.tech/games/$folder_name/images/default.png"
|
||||||
|
else
|
||||||
|
THUMBNAIL="https://senaai.tech/games/$folder_name/$(basename "$THUMBNAIL")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Submit game info to API
|
||||||
|
echo "📤 Submitting game info to API..."
|
||||||
|
curl --location 'http://senaai.tech:10000/api/games/save-with-check' \
|
||||||
|
--header 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data-urlencode "title=$GAME_TITLE" \
|
||||||
|
--data-urlencode "description=$GAME_DESC" \
|
||||||
|
--data-urlencode "url=$GAME_URL" \
|
||||||
|
--data-urlencode "thumbnail=$THUMBNAIL" \
|
||||||
|
--data-urlencode "type=$GAME_TYPE" || true
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
# Show deployment summary
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "🎉 Deployment Completed Successfully!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "📍 Deployed URLs:"
|
||||||
|
echo -e "$DEPLOYED_URLS"
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
28
RCV_QuizSpin/appmanifest.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "RCV-QuizSpin",
|
||||||
|
"short_name": "RCV-QuizSpin",
|
||||||
|
"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
RCV_QuizSpin/bg.mp4
Normal file
24678
RCV_QuizSpin/c2runtime.js
Normal file
1
RCV_QuizSpin/data.js
Normal file
BIN
RCV_QuizSpin/icon-114.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
RCV_QuizSpin/icon-128.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
RCV_QuizSpin/icon-16.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
RCV_QuizSpin/icon-256.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
RCV_QuizSpin/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
RCV_QuizSpin/images/answer-sheet0.png
Normal file
|
After Width: | Height: | Size: 723 KiB |
BIN
RCV_QuizSpin/images/answer-sheet1.png
Normal file
|
After Width: | Height: | Size: 642 KiB |
BIN
RCV_QuizSpin/images/answer-sheet10.png
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
RCV_QuizSpin/images/answer-sheet11.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
RCV_QuizSpin/images/answer-sheet2.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
RCV_QuizSpin/images/answer-sheet3.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
RCV_QuizSpin/images/answer-sheet4.png
Normal file
|
After Width: | Height: | Size: 587 KiB |
BIN
RCV_QuizSpin/images/answer-sheet5.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
RCV_QuizSpin/images/answer-sheet6.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
RCV_QuizSpin/images/answer-sheet7.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
RCV_QuizSpin/images/answer-sheet8.png
Normal file
|
After Width: | Height: | Size: 710 KiB |
BIN
RCV_QuizSpin/images/answer-sheet9.png
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
RCV_QuizSpin/images/batdau_btn-sheet0.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
RCV_QuizSpin/images/bg_win-sheet0.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
RCV_QuizSpin/images/circle-sheet0.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
RCV_QuizSpin/images/circle-sheet1.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
RCV_QuizSpin/images/huongdan-sheet0.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
RCV_QuizSpin/images/l2_-sheet0.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
RCV_QuizSpin/images/l2_2-sheet0.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
RCV_QuizSpin/images/lose_popup-sheet0.png
Normal file
|
After Width: | Height: | Size: 676 KiB |
BIN
RCV_QuizSpin/images/qs_board-sheet0.png
Normal file
|
After Width: | Height: | Size: 760 KiB |
BIN
RCV_QuizSpin/images/qs_board2-sheet0.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
RCV_QuizSpin/images/replay_btn-sheet0.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
RCV_QuizSpin/images/score_effect.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
RCV_QuizSpin/images/sound-sheet0.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
RCV_QuizSpin/images/sound-sheet1.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
RCV_QuizSpin/images/spinq-sheet0.png
Normal file
|
After Width: | Height: | Size: 950 KiB |
BIN
RCV_QuizSpin/images/sprite-sheet0.png
Normal file
|
After Width: | Height: | Size: 155 B |
BIN
RCV_QuizSpin/images/sprite3-sheet0.png
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
RCV_QuizSpin/images/sprite4-sheet0.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
RCV_QuizSpin/images/sprite6-sheet0.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
RCV_QuizSpin/images/sprite7-sheet0.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
RCV_QuizSpin/images/sprite8-sheet0.png
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
RCV_QuizSpin/images/timers4.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
RCV_QuizSpin/images/txt_qs.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
RCV_QuizSpin/images/win_popup-sheet0.png
Normal file
|
After Width: | Height: | Size: 880 KiB |
BIN
RCV_QuizSpin/images/wrong-sheet0.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
RCV_QuizSpin/images/wrong2-sheet0.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
139
RCV_QuizSpin/index.html
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<title>RCV-QuizSpin</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="1920" height="1080">
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 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
RCV_QuizSpin/jquery-3.4.1.min.js
vendored
Normal file
BIN
RCV_QuizSpin/loading-logo.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
RCV_QuizSpin/media/click.ogg
Normal file
BIN
RCV_QuizSpin/media/clock-alarm-8761.ogg
Normal file
BIN
RCV_QuizSpin/media/correct.ogg
Normal file
BIN
RCV_QuizSpin/media/dungsound.ogg
Normal file
BIN
RCV_QuizSpin/media/fail.ogg
Normal file
BIN
RCV_QuizSpin/media/finish.ogg
Normal file
BIN
RCV_QuizSpin/media/hit.ogg
Normal file
BIN
RCV_QuizSpin/media/lose.ogg
Normal file
BIN
RCV_QuizSpin/media/merry-christmas-christmas-dream-268557.ogg
Normal file
64
RCV_QuizSpin/offline.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"version": 1769658677,
|
||||||
|
"fileList": [
|
||||||
|
"data.js",
|
||||||
|
"c2runtime.js",
|
||||||
|
"jquery-3.4.1.min.js",
|
||||||
|
"offlineClient.js",
|
||||||
|
"images/sprite-sheet0.png",
|
||||||
|
"images/l2_-sheet0.png",
|
||||||
|
"images/l2_2-sheet0.png",
|
||||||
|
"images/win_popup-sheet0.png",
|
||||||
|
"images/lose_popup-sheet0.png",
|
||||||
|
"images/replay_btn-sheet0.png",
|
||||||
|
"images/score_effect.png",
|
||||||
|
"images/txt_qs.png",
|
||||||
|
"images/answer-sheet0.png",
|
||||||
|
"images/answer-sheet1.png",
|
||||||
|
"images/answer-sheet2.png",
|
||||||
|
"images/answer-sheet3.png",
|
||||||
|
"images/answer-sheet4.png",
|
||||||
|
"images/answer-sheet5.png",
|
||||||
|
"images/answer-sheet6.png",
|
||||||
|
"images/answer-sheet7.png",
|
||||||
|
"images/answer-sheet8.png",
|
||||||
|
"images/answer-sheet9.png",
|
||||||
|
"images/answer-sheet10.png",
|
||||||
|
"images/answer-sheet11.png",
|
||||||
|
"images/wrong-sheet0.png",
|
||||||
|
"images/wrong2-sheet0.png",
|
||||||
|
"images/qs_board-sheet0.png",
|
||||||
|
"images/circle-sheet0.png",
|
||||||
|
"images/circle-sheet1.png",
|
||||||
|
"images/spinq-sheet0.png",
|
||||||
|
"images/qs_board2-sheet0.png",
|
||||||
|
"images/sound-sheet0.png",
|
||||||
|
"images/sound-sheet1.png",
|
||||||
|
"images/batdau_btn-sheet0.png",
|
||||||
|
"images/huongdan-sheet0.png",
|
||||||
|
"images/bg_win-sheet0.png",
|
||||||
|
"images/timers4.png",
|
||||||
|
"images/sprite3-sheet0.png",
|
||||||
|
"images/sprite4-sheet0.png",
|
||||||
|
"images/sprite6-sheet0.png",
|
||||||
|
"images/sprite7-sheet0.png",
|
||||||
|
"images/sprite8-sheet0.png",
|
||||||
|
"media/finish.ogg",
|
||||||
|
"media/hit.ogg",
|
||||||
|
"media/lose.ogg",
|
||||||
|
"media/dungsound.ogg",
|
||||||
|
"media/correct.ogg",
|
||||||
|
"media/fail.ogg",
|
||||||
|
"media/click.ogg",
|
||||||
|
"media/merry-christmas-christmas-dream-268557.ogg",
|
||||||
|
"media/clock-alarm-8761.ogg",
|
||||||
|
"icon-16.png",
|
||||||
|
"icon-32.png",
|
||||||
|
"icon-114.png",
|
||||||
|
"icon-128.png",
|
||||||
|
"icon-256.png",
|
||||||
|
"loading-logo.png",
|
||||||
|
"tdv_sdk.js",
|
||||||
|
"bg.mp4"
|
||||||
|
]
|
||||||
|
}
|
||||||
53
RCV_QuizSpin/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();
|
||||||
|
|
||||||
|
}());
|
||||||
|
|
||||||
403
RCV_QuizSpin/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);
|
||||||
|
});
|
||||||
807
RCV_QuizSpin/tdv_sdk.js
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
var tdv_sdk = {};
|
||||||
|
tdv_sdk.list = [
|
||||||
|
// ==================== PART 1: MULTIPLE CHOICE (Q1-Q10) - DỄ → TRUNG BÌNH ====================
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"question": "“Hi! My name is Rosy. I am in class 4B.” Who is speaking?",
|
||||||
|
"options": ["Tim", "Billy", "Rosy", "Miss Jones"],
|
||||||
|
"answer": "Rosy",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"question": "Which is a day of the week AFTER Thursday?",
|
||||||
|
"options": ["Wednesday", "Friday", "Monday", "Tuesday"],
|
||||||
|
"answer": "Friday",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"question": "Who works at the airport?",
|
||||||
|
"options": ["Pilot", "Firefighter", "Doctor", "Police officer"],
|
||||||
|
"answer": "Pilot",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"question": "Which place is NOT a workplace?",
|
||||||
|
"options": ["Hospital", "Fire station", "Bedroom", "Police station"],
|
||||||
|
"answer": "Bedroom",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"question": "Choose the correct sentence.",
|
||||||
|
"options": [
|
||||||
|
"She work at the hospital.",
|
||||||
|
"She works at the hospital",
|
||||||
|
"She working at the hospital",
|
||||||
|
"She worked at hospital"
|
||||||
|
],
|
||||||
|
"answer": "She works at the hospital",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"question": "What do the children like at the beach?",
|
||||||
|
"options": [
|
||||||
|
"Surf and play with a ball",
|
||||||
|
"Study English",
|
||||||
|
"Write in notebooks",
|
||||||
|
"Paint pictures"
|
||||||
|
],
|
||||||
|
"answer": "Surf and play with a ball",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"question": "Which number is BETWEEN 29 and 40?",
|
||||||
|
"options": ["30", "20", "50", "29"],
|
||||||
|
"answer": "30",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"question": "Which sentence is correct?",
|
||||||
|
"options": [
|
||||||
|
"I have English on Wednesday.",
|
||||||
|
"I has English on Wednesday.",
|
||||||
|
"I have English in Wednesday.",
|
||||||
|
"I am have English on Wednesday."
|
||||||
|
],
|
||||||
|
"answer": "I have English on Wednesday.",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"question": "Which activity is done in a group?",
|
||||||
|
"options": [
|
||||||
|
"Write in a notebook",
|
||||||
|
"Play with a ball",
|
||||||
|
"Eat a sandwich",
|
||||||
|
"Take a photo"
|
||||||
|
],
|
||||||
|
"answer": "Play with a ball",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"question": "“The firefighter helps people every day.” What does the firefighter do?",
|
||||||
|
"options": [
|
||||||
|
"Eats sandwiches",
|
||||||
|
"Helps people",
|
||||||
|
"Goes to school",
|
||||||
|
"Collects shells"
|
||||||
|
],
|
||||||
|
"answer": "Helps people",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"question": "Which is NOT a fast food?",
|
||||||
|
"options": ["Fries", "Pizza", "Soup", "Chicken"],
|
||||||
|
"answer": "Soup",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"question": "The funny monkey wears a hat and eats a sandwich. Then it takes a photo. Which is TRUE?",
|
||||||
|
"options": [
|
||||||
|
"The monkey eats a sandwich first",
|
||||||
|
"The monkey takes a photo before eating",
|
||||||
|
"The monkey wears a hat first",
|
||||||
|
"The monkey writes in a notebook"
|
||||||
|
],
|
||||||
|
"answer": "The monkey wears a hat first",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"question": "What does Billy like?",
|
||||||
|
"options": ["His school", "His bag", "His teacher", "His toys"],
|
||||||
|
"answer": "His school",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_hello_im_billy_i_am_in_class_4a_today_is_monday_i__b775daf7.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"question": "What is her job?",
|
||||||
|
"options": ["Nurse", "Doctor", "Teacher", "Pilot"],
|
||||||
|
"answer": "Nurse",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_my_mom_works_at_a_hospital_she_is_not_a_doctor_she_130967d2.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"question": "What does the monkey do last?",
|
||||||
|
"options": [
|
||||||
|
"Eats a sandwich",
|
||||||
|
"Takes a photo",
|
||||||
|
"Wears a hat",
|
||||||
|
"Writes in a notebook"
|
||||||
|
],
|
||||||
|
"answer": "Takes a photo",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_the_monkey_wears_a_hat_and_a_scarf_it_eats_a_sandw_ba8a2f89.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"question": "Which food does the speaker prefer?",
|
||||||
|
"options": ["Pizza", "Bubble tea", "Chicken", "Fries"],
|
||||||
|
"answer": "Bubble tea",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_i_have_a_bubble_tea_my_brother_has_pizza_i_like_bu_a94a29b8.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"question": "Why does the speaker like P.E?",
|
||||||
|
"options": [
|
||||||
|
"Because it is difficult",
|
||||||
|
"Because he can read books",
|
||||||
|
"Because he can run and play",
|
||||||
|
"Because it is in the art room"
|
||||||
|
],
|
||||||
|
"answer": "Because he can run and play",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_look_at_the_school_subjects_i_have_english_math_ar_2f34d40d.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"question": "Which sentence is TRUE?",
|
||||||
|
"options": [
|
||||||
|
"He is a police officer",
|
||||||
|
"He works at a fire station",
|
||||||
|
"He works at a hospital",
|
||||||
|
"He collects shells"
|
||||||
|
],
|
||||||
|
"answer": "He works at a fire station",
|
||||||
|
"audio": "https://audio.senaai.tech/audio/Sena_Voice1_my_dad_is_a_firefighter_he_works_at_the_fire_stati_abd4086a.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"question": "In which lunar month does Tet (Vietnamese New Year) usually begin?",
|
||||||
|
"answer": "January",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"question": "Banh Chung is made from sticky rice, green beans, and ____?",
|
||||||
|
"answer": "pork",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"question": "What flower is most popular during Tet in the North of Vietnam?",
|
||||||
|
"answer": "peach blossom",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"question": "Who do Vietnamese usually give lucky money to during Tet?",
|
||||||
|
"answer": "children",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"question": "Which square-shaped cake represents the Earth during Tet?",
|
||||||
|
"answer": "Banh Chung",
|
||||||
|
"audio": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"question": "Where do people usually go during Tet to pray for health and good luck?",
|
||||||
|
"answer": "pagoda",
|
||||||
|
"audio": ""
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
tdv_sdk.currentIndex = 0;
|
||||||
|
tdv_sdk.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]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.start = function () {
|
||||||
|
tdv_sdk.currentIndex = 0;
|
||||||
|
tdv_sdk.listquestion = [];
|
||||||
|
tdv_sdk.totalquestion = tdv_sdk.list.length;
|
||||||
|
for (let i = 0; i < tdv_sdk.totalquestion; i++) {
|
||||||
|
tdv_sdk.listquestion.push(tdv_sdk.list[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.loadquestion = function () {
|
||||||
|
if (tdv_sdk.currentIndex >= tdv_sdk.totalquestion) {
|
||||||
|
console.log("🎉 Game Over! No more questions.");
|
||||||
|
tdv_sdk.curQuestion = null;
|
||||||
|
tdv_sdk.questionState = -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tdv_sdk.curQuestion = tdv_sdk.listquestion[tdv_sdk.currentIndex];
|
||||||
|
tdv_sdk.correctAnswer = tdv_sdk.curQuestion.answer;
|
||||||
|
|
||||||
|
// ✅ CHỈ shuffle nếu có options (Multiple choice)
|
||||||
|
if (Array.isArray(tdv_sdk.curQuestion.options)) {
|
||||||
|
tdv_sdk.shuffleArray(tdv_sdk.curQuestion.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
tdv_sdk.questionState = 0;
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== QUESTION STATE MACHINE ====================
|
||||||
|
// State: 0 = Roll dice, 1 = Chờ start timer, 2 = Đang đếm, 3 = Chờ hiện đáp án, 4 = Chờ đóng layer
|
||||||
|
tdv_sdk.questionState = 0;
|
||||||
|
|
||||||
|
// Lấy state hiện tại (dùng để check trong Construct)
|
||||||
|
tdv_sdk.getQuestionState = function () {
|
||||||
|
return tdv_sdk.questionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set state (dùng khi cần set thủ công)
|
||||||
|
tdv_sdk.setQuestionState = function (state) {
|
||||||
|
tdv_sdk.questionState = state;
|
||||||
|
console.log("📍 Question State set to:", state);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý khi bấm Space - trả về state mới
|
||||||
|
// questionState: 0 = Roll dice, 1 = Start timer, 2 = Đang đếm, 3 = Show answer, 4 = Close layer
|
||||||
|
|
||||||
|
tdv_sdk.handleSpacePress = function () {
|
||||||
|
switch (tdv_sdk.questionState) {
|
||||||
|
case 0:
|
||||||
|
// Space: Roll dice
|
||||||
|
console.log("🎲 Space 0: Roll dice!");
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
// Space: Bắt đầu đếm giờ (chờ hết giờ mới được bấm tiếp)
|
||||||
|
tdv_sdk.questionState = 2;
|
||||||
|
gameTimer.resetQuestionTimer();
|
||||||
|
console.log("▶️ Space 1: Timer started!");
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
// Đang đếm giờ - Bấm Space sẽ dừng timer và hiện đáp án ngay
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
tdv_sdk.questionState = 3;
|
||||||
|
console.log("⏹️ Space 2: Timer stopped! Show answer.");
|
||||||
|
return 2;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
// Space: Hiện đáp án đúng
|
||||||
|
tdv_sdk.questionState = 4;
|
||||||
|
console.log("✅ Space 3: Show correct answer!");
|
||||||
|
return 3;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
// Space: Đóng layer câu hỏi
|
||||||
|
// LƯU Ý: KHÔNG gọi nextQuestion() ở đây vì Construct đã gọi riêng
|
||||||
|
tdv_sdk.questionState = 0;
|
||||||
|
console.log("🚪 Space 4: Close question layer!");
|
||||||
|
return 4;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return tdv_sdk.questionState;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gọi khi bắt đầu hiện câu hỏi (sau khi thỏ di chuyển xong)
|
||||||
|
// QUAN TRỌNG: Reset timer về 15s và KHÔNG start - chờ Space mới start
|
||||||
|
tdv_sdk.startQuestion = function () {
|
||||||
|
// Dừng timer cũ nếu đang chạy
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
// Reset timer về giá trị ban đầu (15s) nhưng KHÔNG start
|
||||||
|
gameTimer.currentTime = gameTimer.questionTime;
|
||||||
|
// Set state = 1 (chờ Space để start timer)
|
||||||
|
tdv_sdk.questionState = 1;
|
||||||
|
console.log("❓ Question ready! Timer set to", gameTimer.questionTime, "s. Press Space to start.");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gọi khi hết giờ (timer = 0)
|
||||||
|
tdv_sdk.onTimeUp = function () {
|
||||||
|
if (tdv_sdk.questionState === 2) {
|
||||||
|
gameTimer.currentTime = 0; // Đảm bảo hiển thị 0
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
tdv_sdk.questionState = 3;
|
||||||
|
console.log("⏰ Time up! Press Space to see answer.");
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gọi khi người chơi trả lời xong (chỉ gọi 1 lần khi đang đếm giờ)
|
||||||
|
tdv_sdk.onAnswered = function () {
|
||||||
|
// Chỉ xử lý nếu đang ở state 2 (đang đếm giờ)
|
||||||
|
if (tdv_sdk.questionState !== 2) {
|
||||||
|
console.log("⚠️ onAnswered skipped - already processed");
|
||||||
|
return tdv_sdk.questionState;
|
||||||
|
}
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
tdv_sdk.questionState = 3;
|
||||||
|
console.log("📝 Answered! Press Space to see answer.");
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check xem câu hỏi hiện tại có phải dạng NGHE không (audio là mảng)
|
||||||
|
// Trả về 1 nếu là Listening Question (Q21-Q30), 0 nếu là câu hỏi thường
|
||||||
|
tdv_sdk.isListeningQuestion = function () {
|
||||||
|
if (!tdv_sdk.curQuestion) return 0;
|
||||||
|
return Array.isArray(tdv_sdk.curQuestion.audio) ? 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCurQuestion = function () {
|
||||||
|
return tdv_sdk.curQuestion.question;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCurOptions = function (index) {
|
||||||
|
if (!tdv_sdk.curQuestion.options) return "";
|
||||||
|
return tdv_sdk.curQuestion.options[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Tính scale phù hợp dựa trên độ dài text đáp án
|
||||||
|
// Trả về giá trị scale từ 0.5 đến 1.0
|
||||||
|
// maxLength: Độ dài tối đa trước khi bắt đầu thu nhỏ (mặc định 12)
|
||||||
|
tdv_sdk.getOptionScale = function (index, maxLength) {
|
||||||
|
var text = tdv_sdk.curQuestion.options[index] || "";
|
||||||
|
var maxLen = maxLength || 12; // Bắt đầu thu nhỏ khi > 12 ký tự
|
||||||
|
var minScale = 0.65; // Scale nhỏ nhất
|
||||||
|
var maxScale = 1; // Scale lớn nhất
|
||||||
|
|
||||||
|
var len = text.length;
|
||||||
|
|
||||||
|
if (len <= maxLen) {
|
||||||
|
return maxScale; // Giữ nguyên scale = 1 nếu text ngắn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính toán: giảm dần từ 1.0 đến 0.5 theo độ dài
|
||||||
|
var ratio = Math.max(0, 1 - (len - maxLen) / 25);
|
||||||
|
var newScale = minScale + (maxScale - minScale) * ratio;
|
||||||
|
|
||||||
|
// Làm tròn 2 chữ số thập phân
|
||||||
|
return Math.round(Math.max(minScale, newScale) * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phiên bản linh hoạt hơn - tính scale tuyến tính
|
||||||
|
// Điều chỉnh scale mạnh hơn cho text dài
|
||||||
|
tdv_sdk.getAnswerScale = function (index, maxLen) {
|
||||||
|
// Sử dụng getCurOptions thay vì getAnswerByIndex
|
||||||
|
var answer = tdv_sdk.getCurOptions(index);
|
||||||
|
if (!answer) return 1;
|
||||||
|
|
||||||
|
var standardLen = maxLen || 8; // Độ dài chuẩn (giảm từ 10 xuống 8)
|
||||||
|
var len = String(answer).length;
|
||||||
|
|
||||||
|
// Scale mạnh hơn cho text dài
|
||||||
|
if (len <= standardLen) return 1; // <= 8 ký tự: scale 1.0
|
||||||
|
if (len <= 10) return 0.9; // 9-10 ký tự
|
||||||
|
if (len <= 12) return 0.85; // 11-12 ký tự
|
||||||
|
if (len <= 14) return 0.8; // 13-14 ký tự (Lunar New Year)
|
||||||
|
if (len <= 18) return 0.75; // 15-18 ký tự
|
||||||
|
if (len <= 22) return 0.7; // 19-22 ký tự
|
||||||
|
return 0.65; // > 22 ký tự: scale tối thiểu
|
||||||
|
};
|
||||||
|
|
||||||
|
// HÀM MỚI: Nhận TEXT trực tiếp thay vì index (tránh lỗi LoopIndex)
|
||||||
|
// Sử dụng: tdv_sdk.getTextScale("Lunar New Year")
|
||||||
|
tdv_sdk.getTextScale = function (text, maxLen) {
|
||||||
|
if (!text) return 1;
|
||||||
|
|
||||||
|
var standardLen = maxLen || 8;
|
||||||
|
var len = String(text).length;
|
||||||
|
|
||||||
|
// Scale mạnh hơn cho text dài
|
||||||
|
if (len <= standardLen) return 1; // <= 8 ký tự: scale 1.0
|
||||||
|
if (len <= 10) return 0.85; // 9-10 ký tự
|
||||||
|
if (len <= 12) return 0.75; // 11-12 ký tự
|
||||||
|
if (len <= 14) return 0.65; // 13-14 ký tự
|
||||||
|
if (len <= 18) return 0.55; // 15-18 ký tự
|
||||||
|
if (len <= 22) return 0.5; // 19-22 ký tự
|
||||||
|
return 0.45; // > 22 ký tự: scale tối thiểu
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCurCorrectOption = function () {
|
||||||
|
return tdv_sdk.correctAnswer;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCorrectCount = function () {
|
||||||
|
return tdv_sdk.answers;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCurImageUrl = function () {
|
||||||
|
return tdv_sdk.curQuestion.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.nextQuestion = function () {
|
||||||
|
tdv_sdk.currentIndex++;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getCurIndex = function () {
|
||||||
|
return tdv_sdk.currentIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alias cho getCurIndex (tương thích với các game khác)
|
||||||
|
tdv_sdk.getCurrentLevel = function () {
|
||||||
|
return tdv_sdk.currentIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getTotalQuestion = function () {
|
||||||
|
return tdv_sdk.totalquestion;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check xem còn câu hỏi không (trả về 1 nếu còn, 0 nếu hết)
|
||||||
|
tdv_sdk.hasMoreQuestions = function () {
|
||||||
|
return tdv_sdk.currentIndex < tdv_sdk.totalquestion ? 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check xem đây có phải câu hỏi cuối cùng không (trả về 1 nếu đúng)
|
||||||
|
tdv_sdk.isLastQuestion = function () {
|
||||||
|
return tdv_sdk.currentIndex >= tdv_sdk.totalquestion - 1 ? 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check xem game đã kết thúc chưa (trả về 1 nếu hết câu hỏi, 0 nếu còn)
|
||||||
|
// Dùng trong Construct: If Browser.ExecJS("tdv_sdk.isGameOver()") = 1 → Game Over
|
||||||
|
tdv_sdk.isGameOver = function () {
|
||||||
|
return tdv_sdk.currentIndex >= tdv_sdk.totalquestion ? 1 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lấy số câu hỏi còn lại
|
||||||
|
tdv_sdk.getRemainingQuestions = function () {
|
||||||
|
return Math.max(0, tdv_sdk.totalquestion - tdv_sdk.currentIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset lại từ đầu (shuffle lại câu hỏi)
|
||||||
|
tdv_sdk.reloadQuestions = function () {
|
||||||
|
tdv_sdk.currentIndex = 0;
|
||||||
|
tdv_sdk.listquestion = [];
|
||||||
|
for (let i = 0; i < tdv_sdk.totalquestion; i++) {
|
||||||
|
tdv_sdk.listquestion.push(tdv_sdk.list[i]);
|
||||||
|
}
|
||||||
|
console.log("🔄 Questions reloaded and shuffled!");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getId = function (index) {
|
||||||
|
return tdv_sdk.list[index].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.getAnswers = function (index) {
|
||||||
|
return tdv_sdk.answers[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- AUDIO: Hỗ trợ cả string đơn và mảng audio ---
|
||||||
|
tdv_sdk.currentAudioQueue = [];
|
||||||
|
tdv_sdk.currentAudioIndex = 0;
|
||||||
|
|
||||||
|
tdv_sdk.playSound = function (name) {
|
||||||
|
console.log(`Play sound: ${name}`);
|
||||||
|
const audioSrc = `https://audio.senaai.vn/audio/en_female_1_${name.toLowerCase()}.mp3`;
|
||||||
|
const audio = new Audio(audioSrc);
|
||||||
|
audio.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phát audio câu hỏi (hỗ trợ cả string đơn và mảng)
|
||||||
|
tdv_sdk.playSoundQuestion = function (rate) {
|
||||||
|
// Dừng audio cũ nếu đang chạy
|
||||||
|
if (window.audio && !window.audio.paused) {
|
||||||
|
window.audio.pause();
|
||||||
|
window.audio.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tdv_sdk.currentAudioQueue = [];
|
||||||
|
tdv_sdk.currentAudioIndex = 0;
|
||||||
|
|
||||||
|
var audioSource = tdv_sdk.curQuestion.audio;
|
||||||
|
var playbackRate = rate || 1;
|
||||||
|
|
||||||
|
if (!audioSource) {
|
||||||
|
console.log("Không có audio cho câu hỏi này.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(audioSource)) {
|
||||||
|
// Mảng audio (Hội thoại: context + question)
|
||||||
|
tdv_sdk.currentAudioQueue = audioSource;
|
||||||
|
tdv_sdk.playNextInQueue(playbackRate);
|
||||||
|
} else {
|
||||||
|
// Audio đơn
|
||||||
|
tdv_sdk.playSingleAudio(audioSource, playbackRate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.playSingleAudio = function (src, rate) {
|
||||||
|
var audio = new Audio(src);
|
||||||
|
audio.playbackRate = rate;
|
||||||
|
window.audio = audio;
|
||||||
|
audio.play().catch(e => console.error("Lỗi phát audio:", e));
|
||||||
|
console.log(`Đang phát: ${src}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.playNextInQueue = function (rate) {
|
||||||
|
if (tdv_sdk.currentAudioIndex >= tdv_sdk.currentAudioQueue.length) {
|
||||||
|
console.log("Đã phát hết đoạn hội thoại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var src = tdv_sdk.currentAudioQueue[tdv_sdk.currentAudioIndex];
|
||||||
|
var audio = new Audio(src);
|
||||||
|
audio.playbackRate = rate;
|
||||||
|
window.audio = audio;
|
||||||
|
|
||||||
|
audio.onended = function () {
|
||||||
|
tdv_sdk.currentAudioIndex++;
|
||||||
|
setTimeout(function () {
|
||||||
|
tdv_sdk.playNextInQueue(rate);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.play().catch(e => console.error("Lỗi phát audio:", e));
|
||||||
|
console.log(`Đang phát đoạn ${tdv_sdk.currentAudioIndex + 1}: ${src}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.stopSound = function () {
|
||||||
|
if (window.audio && !window.audio.paused) {
|
||||||
|
window.audio.pause();
|
||||||
|
window.audio.currentTime = 0;
|
||||||
|
}
|
||||||
|
tdv_sdk.currentAudioQueue = [];
|
||||||
|
tdv_sdk.currentAudioIndex = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tất cả 4 đáp án sẽ có cùng kích thước chữ
|
||||||
|
tdv_sdk.getUniformScale = function () {
|
||||||
|
if (!tdv_sdk.curQuestion) return 1;
|
||||||
|
|
||||||
|
var options = tdv_sdk.curQuestion.options;
|
||||||
|
var maxLen = 0;
|
||||||
|
|
||||||
|
// Tìm độ dài của đáp án dài nhất
|
||||||
|
for (var i = 0; i < options.length; i++) {
|
||||||
|
var len = String(options[i] || "").length;
|
||||||
|
if (len > maxLen) maxLen = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính scale dựa trên đáp án dài nhất
|
||||||
|
if (maxLen <= 8) return 1;
|
||||||
|
if (maxLen <= 10) return 0.95;
|
||||||
|
if (maxLen <= 12) return 0.9;
|
||||||
|
if (maxLen <= 14) return 0.85;
|
||||||
|
if (maxLen <= 18) return 0.8;
|
||||||
|
if (maxLen <= 22) return 0.75;
|
||||||
|
return 0.7;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
tdv_sdk.goHome = function () {
|
||||||
|
window.location.href = "../";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== QUESTION TIMER (Mỗi câu hỏi 10 giây) ====================
|
||||||
|
var gameTimer = {};
|
||||||
|
gameTimer.questionTime = 10; // Thời gian mỗi câu hỏi (giây)
|
||||||
|
gameTimer.currentTime = gameTimer.questionTime;
|
||||||
|
gameTimer.isRunning = false;
|
||||||
|
gameTimer.intervalId = null;
|
||||||
|
|
||||||
|
// Bắt đầu đếm ngược cho câu hỏi hiện tại
|
||||||
|
gameTimer.startTimer = function () {
|
||||||
|
if (gameTimer.isRunning) {
|
||||||
|
console.warn("Timer is already running.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameTimer.isRunning = true;
|
||||||
|
gameTimer.intervalId = setInterval(function () {
|
||||||
|
if (gameTimer.currentTime > 0) {
|
||||||
|
gameTimer.currentTime--;
|
||||||
|
} else {
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
console.log("⏰ Question time is up!");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
console.log("⏱️ Question timer started:", gameTimer.questionTime, "seconds.");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.stopTimer = function () {
|
||||||
|
if (gameTimer.intervalId) {
|
||||||
|
clearInterval(gameTimer.intervalId);
|
||||||
|
gameTimer.intervalId = null;
|
||||||
|
gameTimer.isRunning = false;
|
||||||
|
console.log("Timer stopped.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.resetTimer = function () {
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
gameTimer.currentTime = gameTimer.questionTime;
|
||||||
|
console.log("⏱️ Timer reset to:", gameTimer.questionTime, "seconds.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset timer cho câu hỏi mới (gọi khi loadquestion) - CHỈ RESET, KHÔNG START
|
||||||
|
gameTimer.prepareQuestionTimer = function () {
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
gameTimer.currentTime = gameTimer.questionTime;
|
||||||
|
console.log("⏱️ Timer prepared:", gameTimer.questionTime, "seconds. Press Space to start.");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset VÀ start timer (gọi khi bấm Space)
|
||||||
|
gameTimer.resetQuestionTimer = function () {
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
gameTimer.currentTime = gameTimer.questionTime;
|
||||||
|
gameTimer.startTimer();
|
||||||
|
console.log("🔄 Timer started:", gameTimer.questionTime, "seconds.");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set thời gian mỗi câu hỏi (mặc định 10 giây)
|
||||||
|
gameTimer.setQuestionTime = function (seconds) {
|
||||||
|
gameTimer.questionTime = seconds || 10;
|
||||||
|
console.log("⚙️ Question time set to:", gameTimer.questionTime, "seconds.");
|
||||||
|
return gameTimer.questionTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.getFormattedTime = function () {
|
||||||
|
if (gameTimer.currentTime < 0) return "0:00";
|
||||||
|
|
||||||
|
const minutes = Math.floor(gameTimer.currentTime / 60);
|
||||||
|
const seconds = gameTimer.currentTime % 60;
|
||||||
|
const formattedSeconds = seconds < 10 ? '0' + seconds : seconds;
|
||||||
|
|
||||||
|
return minutes + ':' + formattedSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.getCurrentTimeValue = function () {
|
||||||
|
return gameTimer.currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trả về thời gian hiển thị (luôn = 0 nếu đã hết giờ)
|
||||||
|
gameTimer.getDisplayTime = function () {
|
||||||
|
// Nếu đã hết giờ (state >= 3), luôn trả về 0
|
||||||
|
if (tdv_sdk.questionState >= 3) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, gameTimer.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.pauseTimer = function () {
|
||||||
|
if (gameTimer.isRunning) {
|
||||||
|
clearInterval(gameTimer.intervalId);
|
||||||
|
gameTimer.intervalId = null;
|
||||||
|
gameTimer.isRunning = false;
|
||||||
|
console.log("Timer paused at:", gameTimer.getFormattedTime());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameTimer.resumeTimer = function () {
|
||||||
|
if (!gameTimer.isRunning && gameTimer.currentTime > 0) {
|
||||||
|
gameTimer.isRunning = true;
|
||||||
|
gameTimer.intervalId = setInterval(function () {
|
||||||
|
if (gameTimer.currentTime > 0) {
|
||||||
|
gameTimer.currentTime--;
|
||||||
|
} else {
|
||||||
|
gameTimer.stopTimer();
|
||||||
|
console.log("Time is up!");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
console.log("Timer resumed from:", gameTimer.getFormattedTime());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== GAME SCORE ====================
|
||||||
|
var gameScore = {};
|
||||||
|
gameScore.currentScore = 0;
|
||||||
|
|
||||||
|
gameScore.addScore = function (points) {
|
||||||
|
const pts = parseInt(points) || 0;
|
||||||
|
if (pts > 0) {
|
||||||
|
gameScore.currentScore += pts;
|
||||||
|
console.log(`Score added: ${pts}. New score: ${gameScore.currentScore}`);
|
||||||
|
}
|
||||||
|
return gameScore.currentScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameScore.resetScore = function () {
|
||||||
|
gameScore.currentScore = 0;
|
||||||
|
console.log("Score reset to 0.");
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameScore.getScore = function () {
|
||||||
|
return gameScore.currentScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameScore.deductScore = function (points) {
|
||||||
|
const pts = parseInt(points) || 0;
|
||||||
|
if (gameScore.currentScore >= pts) {
|
||||||
|
gameScore.currentScore -= pts;
|
||||||
|
console.log(`Score deducted: ${pts}. New score: ${gameScore.currentScore}`);
|
||||||
|
} else {
|
||||||
|
gameScore.currentScore = 0;
|
||||||
|
console.log(`Score deducted. New score is 0.`);
|
||||||
|
}
|
||||||
|
return gameScore.currentScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== GAME LIVES ====================
|
||||||
|
var gameLives = {};
|
||||||
|
gameLives.maxLives = 3;
|
||||||
|
gameLives.currentLives = gameLives.maxLives;
|
||||||
|
|
||||||
|
gameLives.resetLives = function () {
|
||||||
|
gameLives.currentLives = gameLives.maxLives;
|
||||||
|
console.log("Lives reset to:", gameLives.maxLives);
|
||||||
|
return gameLives.currentLives;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameLives.loseLife = function () {
|
||||||
|
if (gameLives.currentLives > 0) {
|
||||||
|
gameLives.currentLives--;
|
||||||
|
console.log("Lost 1 life. Remaining:", gameLives.currentLives);
|
||||||
|
} else {
|
||||||
|
console.log("No lives left. Game Over condition.");
|
||||||
|
}
|
||||||
|
return gameLives.currentLives;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameLives.getLives = function () {
|
||||||
|
return gameLives.currentLives;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== INSTRUCTIONS ====================
|
||||||
|
tdv_sdk.getQuizInstructions = function () {
|
||||||
|
const instructions =
|
||||||
|
" 🌟 HƯỚNG DẪN GAME: HÀNH TRÌNH CỦA THỎ 🌟\n\n" +
|
||||||
|
"🎯 MỤC TIÊU:\n" +
|
||||||
|
" Giúp chú thỏ đi hết hành trình và đến đích! Trả lời đúng các câu hỏi để ghi thật nhiều điểm trên đường đi.\n\n" +
|
||||||
|
"🌀 CÁCH CHƠI:\n" +
|
||||||
|
" 1. Lắc xúc xắc: Chạm vào xúc xắc để quay.\n" +
|
||||||
|
" 2. Di chuyển: Chú thỏ sẽ đi theo số chấm xuất hiện trên xúc xắc.\n" +
|
||||||
|
" 3. Trả lời câu hỏi: Khi chú thỏ dừng lại, chọn đáp án đúng hiển thị trên màn hình.\n" +
|
||||||
|
" 4. Tiếp tục hành trình: Sau khi trả lời, bạn có thể quay lại xúc xắc ngay để đi tiếp.\n\n" +
|
||||||
|
"💡 ĐIỂM SỐ VÀ THỬ THÁCH:\n" +
|
||||||
|
" ✅ Đúng: +100 điểm.\n" +
|
||||||
|
" ❌ Sai: Không bị trừ điểm hay phạt.\n" +
|
||||||
|
" 🐺 Nguy hiểm: Nếu dừng ở ô **'SÓI'** (Wolf), bạn sẽ bị trừ 100 điểm ngay lập tức.\n\n" +
|
||||||
|
"🍀 Cùng chú thỏ phiêu lưu, trả lời câu hỏi và ghi thật nhiều điểm trên hành trình nhé!";
|
||||||
|
|
||||||
|
return instructions;
|
||||||
|
};
|
||||||
|
|
||||||
|
tdv_sdk.hasAudio = function () {
|
||||||
|
if (!tdv_sdk.curQuestion) return 0;
|
||||||
|
var audio = tdv_sdk.curQuestion.audio;
|
||||||
|
// Check nếu là mảng và có item
|
||||||
|
if (Array.isArray(audio) && audio.length > 0) return 1;
|
||||||
|
// Check nếu là string và không rỗng
|
||||||
|
if (typeof audio === 'string' && audio.trim() !== '') return 1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
3
readme.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Sequence Word TextOnly
|
||||||
|
Mô tả của trò chơi
|
||||||
|
Sequence RCV_QuizSpin
|
||||||