up
All checks were successful
Deploy to Production / deploy (push) Successful in 5s

This commit is contained in:
lubukhu
2026-01-28 19:53:49 +07:00
commit ab6f2657a3
42 changed files with 25790 additions and 0 deletions

View 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 "=========================================="

View File

@@ -0,0 +1,28 @@
{
"name": "Sequence",
"short_name": "Sequence",
"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"
}]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

View 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>Sequence</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>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

View File

@@ -0,0 +1,39 @@
{
"version": 1769514386,
"fileList": [
"data.js",
"c2runtime.js",
"jquery-3.4.1.min.js",
"offlineClient.js",
"images/btn_check-sheet0.png",
"images/pause-sheet0.png",
"images/slot-sheet0.png",
"images/txt_texttimer.png",
"images/txt_worditem.png",
"images/5sosarahtakesoff-sheet0.png",
"images/senaaikhoi-sheet0.png",
"images/checker_wrong_correct-sheet0.png",
"images/checker_wrong_correct-sheet1.png",
"images/btn_setting-sheet0.png",
"images/panel-sheet0.png",
"images/txt_question.png",
"images/btn_pause-sheet0.png",
"images/btn_music-sheet0.png",
"images/btn_music-sheet1.png",
"images/panel_pause-sheet0.png",
"images/layer-sheet0.png",
"images/newwordpng-sheet0.png",
"media/click.ogg",
"media/correct.ogg",
"media/error-010-206498.ogg",
"media/immersivecontrol-button-click-sound-463065.ogg",
"icon-16.png",
"icon-32.png",
"icon-114.png",
"icon-128.png",
"icon-256.png",
"loading-logo.png",
"tdv_sdk.js",
"sena_sdk.js"
]
}

View 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();
}());

View 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;
}

View 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);
});

View File

@@ -0,0 +1 @@
// hé lô quơ

3
readme.md Normal file
View File

@@ -0,0 +1,3 @@
Sequence Word TextOnly
Mô tả của trò chơi
Sequence definition_spelling_sequence_sentence

BIN
source/Sequence.capx Normal file

Binary file not shown.