@falcongames/game-client-sdk
v1.3.1
Published
Falcon Game Client SDK: shell-app bridge, ads, IAP/shop logic, remove-ads, lives, review, analytics, user data for Falcon web games. Plain JS, no dependencies. Logic only: no UI is included.
Maintainers
Readme
Falcon Game Client SDK
@falcongames/game-client-sdk là thư viện JavaScript dùng chung cho mọi game web chạy trong shell app của Falcon. SDK đảm nhận toàn bộ phần giao tiếp với app và các luồng sản phẩm; game chỉ vẽ giao diện và gọi đúng hàm tại đúng thời điểm.
Tài liệu này dành cho người làm game. Mục 1 đến 11 là phần cần đọc để tích hợp; mục 12 là hướng dẫn chi tiết từng module, đọc khi triển khai module đó. App team và người bảo trì SDK đọc thêm docs/PROTOCOL.md.
1. Tính năng
| Module | Vai trò |
|---|---|
| bridge | Giao tiếp với shell app qua Web Bridge: gửi, nhận, chờ phản hồi theo callbackId, đọc biến môi trường, biến CSS cho status bar. |
| userData | Dữ liệu người dùng: đọc, ghi, gộp với bản trên server, đồng bộ một lần mỗi phiên. |
| analytics | log_action, log_level, bộ đếm theo màn chơi và trợ giúp theo lượt chơi. |
| ads | Rewarded, interstitial và bộ điều kiện hiển thị interstitial. |
| iap, shop | Bảng gói coin, lấy giá, mua, cấp hàng đúng một lần, giao dịch trả sau, kết nối giao diện shop. |
| removeAds | Gói Remove Ads: đồng bộ trạng thái sở hữu, mua, khôi phục giao dịch. |
| lives | Hệ thống mạng hồi theo thời gian thực. |
| review | In-app review, hỏi đúng một lần. |
| pause | Cờ tạm dừng vòng render khi shop hoặc popup đang mở. |
| mock | Bridge giả để chạy trên trình duyệt và trong kiểm thử tự động. |
Mặc định của SDK là một prototype: init({ gameId }) chỉ dùng bridge, userData, analytics và review. log_level là bắt buộc với mọi game, kể cả prototype. Bốn module ads, lives, removeAds và shop tắt sẵn và bật khi init() nhận cấu hình của chúng (mục 5).
SDK là JavaScript thuần theo chuẩn ES2020, không phụ thuộc thư viện nào, chiếm khoảng 60 KB trong tệp build. Giao diện không nằm trong SDK: header, popup, shop và các khối khác do bộ rules của studio cung cấp dưới dạng template (rules/README.md).
2. Yêu cầu
- Node 20.19 trở lên hoặc 22.12 trở lên để build game bằng Vite 8.
- Game build ra một tệp HTML duy nhất, chạy trên Android WebView; SDK và cấu hình build nhắm ES2020.
- Khi chạy trong shell app,
window.AndroidBridgevàwindow.onNativeMessagelà kênh giao tiếp; khi chạy trên trình duyệt thường, mọi yêu cầu nhận câu trả lời mặc định để không chặn luồng phát triển.
3. Cài đặt
npm install @falcongames/game-client-sdkimport FalconSDK from '@falcongames/game-client-sdk';Package có điểm vào ESM (import), điểm vào CommonJS (require) và định nghĩa kiểu TypeScript. tsconfig.json của game dùng "moduleResolution": "bundler". Lệnh tạo game trắng và cấu hình Vite build ra một tệp nằm trong prepare/README.md.
4. Bắt đầu nhanh
Prototype cần những gì
| Việc | Bắt buộc | Cách làm |
|---|---|---|
| Build ra một tệp dist/index.html | có | Vite với vite-plugin-singlefile, cấu hình trong prepare/README.md. |
| Khởi tạo SDK một lần, sớm nhất có thể | có | FalconSDK.init({ gameId }). SDK gửi web_loaded cho app. |
| log_level cho mỗi màn chơi | có | turn.begin() khi vào màn, turn.tick(dt) trong vòng render, turn.end('Pass' \| 'Fail') khi màn kết thúc. |
| Nạp dữ liệu trước màn đầu | có | userData.load() rồi mới vào màn. |
| Báo sẵn sàng | có | bridge.gameReady() sau khi vẽ khung hình đầu tiên. |
| Quảng cáo, mạng, Remove Ads, shop, bảng gói, giao diện | không | Các module này tắt sẵn. Bật khi game lên bản thương mại (mục 5 và 12). |
Mã của một prototype. Mỗi dòng ghi rõ bắt buộc hay tuỳ chọn:
import FalconSDK from '@falcongames/game-client-sdk';
// 1. Khởi tạo: bắt buộc, gọi một lần, trước mọi lời gọi khác. SDK gửi web_loaded cho app.
const SDK = FalconSDK.init({
gameId: 'mygame', // tuỳ chọn, nên đặt: tiền tố localStorage, mỗi game một giá trị (mục 5)
});
// 2. log_level: bắt buộc. Mỗi màn chơi là một lượt: Start khi vào màn, Pass hoặc Fail khi kết thúc.
function startLevel(level: number) {
SDK.analytics.turn.begin({
level, // bắt buộc: số màn
difficulty: 'normal', // tuỳ chọn, mặc định 'normal': 'easy' | 'normal' | 'hard' theo quy ước của game
});
}
function endLevel(won: boolean, progressPercent: number) {
SDK.analytics.turn.end(
won ? 'Pass' : 'Fail', // bắt buộc: 'Pass' | 'Fail' | 'Skip'; một lần mỗi lượt
{ levelProgress: progressPercent }, // tuỳ chọn: 0 đến 100 khi Fail; Pass luôn gửi 100
);
}
// 3. Thời gian chơi: bắt buộc để duration của log_level đúng. Gọi mỗi khung hình khi đang chơi thật.
function frame(dt: number) {
if (!document.hidden) SDK.analytics.turn.tick(dt); // dt: số giây từ khung hình trước
// ... cập nhật và vẽ game
}
// 4. Nạp dữ liệu rồi báo sẵn sàng: bắt buộc, theo đúng thứ tự này.
SDK.userData.load().then(() => {
startLevel(1);
SDK.bridge.gameReady(); // bắt buộc: sau khi vẽ khung hình đầu tiên
});Không cần trong prototype: ads, lives, removeAds, shop, packs, iap.onGrant, pause, lưu ví bằng userData.set(), giao diện; tất cả thuộc bản thương mại (mục 12).
log_level là bắt buộc với mọi game, kể cả prototype: Start khi vào màn, Pass hoặc Fail khi màn kết thúc, duration tính từ tick(dt). SDK tự thêm playTurnId, playCount, failCount và comeBackAfterFirstPass (mục 12.3).
gameId là tiền tố của mọi khoá localStorage. Các game phục vụ từ cùng một host CDN dùng chung một origin, tức chung một localStorage, nên mỗi game cần một giá trị riêng. Bỏ qua gameId thì SDK tự sinh game-<mã băm đường dẫn thư mục của trang>, ổn định qua các lần mở và khác nhau giữa các game, nhưng đổi khi đường dẫn đổi; đặt rõ gameId trước khi phát hành.
Đó là toàn bộ phần tích hợp. Khi chạy với ?mock=1 (mục 8), thứ tự message gửi cho app giả là web_loaded, request_user_data ở lần mở đầu, save_user_data, log_level với status: "Start", rồi game_ready; console có dòng khởi động kèm Config và không có cảnh báo nào. Không có message nào tới store.
Khi game cần một tính năng sản phẩm, bật module tương ứng bằng cấu hình trong init() (mục 5) và làm theo hướng dẫn của module đó (mục 12). Bản đầy đủ của một game thương mại nằm ở đầu mục 12 và trong rules/example/src/main.ts.
5. Cấu hình init(options)
Bật và tắt module
Bốn module ads, lives, removeAds và shop tắt sẵn. Quy tắc:
| Giá trị truyền vào | Kết quả |
|---|---|
| không truyền | Tắt. |
| false | Tắt. |
| true | Bật với giá trị mặc định. |
| một đối tượng, kể cả {} | Bật, các trường truyền vào ghi đè mặc định. |
| { enabled: false, ... } | Tắt. |
Module tắt trả lời mọi lời gọi ngay tại chỗ, không gửi message nào cho app, và ghi một dòng thông tin ở lần gọi đầu tiên trong phiên:
| Module tắt | Hành vi |
|---|---|
| ads | showRewarded(), showInterstitial() và maybeShowInterstitial() trả false. Không có analytics quảng cáo. |
| lives | count bằng max, isFull và canPlay() là true, msToNext() là 0. consume(), add() và setFull() không làm gì và không ghi vào dữ liệu lưu. |
| removeAds | isRemoved là false, getPrice() trả priceFallback, buy() trả { result: 'failed', skipped: true }, restoreFlow() trả false. Không gửi get_remove_ads_status lúc khởi động. |
| shop | shop.open() trả false, shop.isEnabled() là false. Không tìm giao diện shop, không có FSDK-501. |
Config trong dòng khởi động của console ghi enabled của từng module.
Bảng tuỳ chọn
Không tuỳ chọn nào bắt buộc; init() không tham số vẫn chạy. Nên đặt gameId. Trong cấu hình của một module, mọi trường đều tuỳ chọn và có giá trị mặc định; chỉ cần ghi trường muốn đổi. Giá trị mặc định của một module là giá trị khi module được bật.
| Tuỳ chọn | Mặc định | Ý nghĩa |
|---|---|---|
| gameId | game-<mã băm đường dẫn> | Tiền tố cho các khoá localStorage: <gameId>_save, <gameId>_level_stats, <gameId>_rate_asked. Không truyền thì SDK băm đường dẫn thư mục của trang và ghi một dòng thông tin; giá trị này ổn định qua các lần mở nhưng đổi khi đường dẫn đổi. Khi chuyển game cũ sang SDK, dùng đúng tiền tố cũ để giữ dữ liệu người chơi. |
| ads | { enabled: false, minLevel: 15, capSec: 120, chance: 0.5, forceInterstitial: true, rewardedInBrowser: true, interstitialInBrowser: false } | Điều kiện hiển thị interstitial và câu trả lời trên trình duyệt thường. |
| lives | { enabled: false, max: 5, regenSec: 1800 } | Số mạng tối đa và số giây hồi một mạng. |
| removeAds | { enabled: false, productId: 'remove_ads', priceFallback: '$4.99', autoSync: true } | Mã sản phẩm, giá hiển thị khi chưa lấy được giá từ store, hỏi trạng thái sở hữu lúc khởi động. |
| shop | { enabled: false } | Bật module shop. Rules của studio yêu cầu bật trong mọi bản phát hành, cùng lúc chèn giao diện shop. Bật mà trang không có giao diện shop thì SDK ghi lỗi FSDK-501. |
| shopLevel | không có | Hàm trả về màn hiện tại, dùng cho analytics của shop và giao dịch trả sau. |
| packs | [] | Bảng gói coin { productId, coins, amt, price }. Bỏ trống khi dùng giao diện shop theo rules, vì PACKS[] trong giao diện đó là nguồn chuẩn (mục 12.5). |
| iap | { productsTimeoutMs: 30000, ledgerSize: 50, simDelayMs: 1200, priceMarkets: { vn }, badge: true } | Tham số kỹ thuật của mua hàng, thường không cần thay đổi. |
| userData | { serverTimeoutMs: 5000, saveTimeoutMs: 10000, strategy: 'local-first', syncOnLoad: true } | Thời gian chờ request_user_data và save_user_data; 'server-first' luôn hỏi server trước; syncOnLoad: false tắt lần đẩy dữ liệu lên app sau load(). |
| review | { askedKeySuffix: 'rate_asked' } | Hậu tố của khoá đánh dấu đã hỏi đánh giá. |
| log | true | false tắt các dòng thông tin, gồm dòng khởi động. Cảnh báo và lỗi luôn được ghi. |
| autoWebLoaded | true | Gửi web_loaded ngay trong init(). |
| safeArea | true | Đặt biến CSS --sbh trên <html> theo chiều cao status bar. |
init() chỉ có tác dụng ở lần gọi đầu; lần gọi thứ hai bị bỏ qua kèm cảnh báo FSDK-001. SDK.config() trả về cấu hình đang có hiệu lực.
6. Tham chiếu API
Quy ước: tham số có dấu ? là tuỳ chọn, tham số không có dấu là bắt buộc; giá trị mặc định ghi trong mô tả. Chữ ký đầy đủ nằm trong dist/falcon-sdk.d.ts. Mục 12 giải thích cách dùng từng module.
bridge
| Hàm | Mô tả |
|---|---|
| isNative() | true khi window.AndroidBridge tồn tại. |
| send(action, data?) | Gửi một chiều. data mặc định {}. |
| request(action, data?, opts?) | Gửi và chờ phản hồi theo callbackId. opts: timeoutMs? (không có thì chờ không giới hạn), timeoutReply? (trả về khi hết giờ), browserReply? (trả về ngay trên trình duyệt thường), prefix? của callbackId. |
| on(action, fn) | Nghe message từ app theo tên action. Trả về hàm huỷ. |
| env | { statusBarHeight, statusBarHeightPx, language, isOnline, appActive }, tự cập nhật khi app đẩy sự kiện. |
| applySafeArea(cssVar?) | Đặt biến CSS theo chiều cao status bar. Mặc định '--sbh'. |
| webLoaded(), gameReady(), quit() | Báo trạng thái cho app. |
| readEnv(), languages | Đọc lại biến môi trường từ window; danh sách ngôn ngữ app báo. |
| newCallbackId(prefix?), settle(callbackId, data), pendingCount() | Nâng cao: tự tạo callbackId, tự giải quyết một yêu cầu đang chờ, số yêu cầu đang chờ. Dùng khi viết bridge giả riêng. |
if (SDK.bridge.isNative()) console.log('đang chạy trong shell app');
SDK.bridge.send('log_action', { eventName: 'remove_ads_open', currentLevel: level }); // một chiều; thường dùng analytics.logAction
const reply = await SDK.bridge.request('get_products', { productIds: ['coin_1'] }, {
timeoutMs: 30000, timeoutReply: { products: [] }, browserReply: { products: [] },
});
const off = SDK.bridge.on('network_changed', (d) => { banner.hidden = d.online; }); // payload thô của app, mục PROTOCOL.md
off(); // thôi nghe
header.style.paddingTop = `calc(var(--sbh, 0px) + 8px)`; // --sbh do applySafeArea() đặt
console.log(SDK.bridge.env.language, SDK.bridge.env.appActive);userData
| Hàm | Mô tả |
|---|---|
| load(opts?) | Promise của đối tượng dữ liệu. Thứ tự đọc: ?data=, localStorage, server. opts.force? true hỏi server kể cả khi đã có bản cục bộ. |
| get() | Đối tượng dữ liệu hiện tại. |
| set(patch) | Gộp patch vào dữ liệu hiện tại rồi lưu. |
| update(fn) | fn(data) sửa tại chỗ hoặc trả về đối tượng mới, rồi lưu. |
| save() | Lưu đối tượng hiện tại. |
| batch(fn) | Gộp mọi lần lưu bên trong fn thành một lần. Trả về giá trị của fn. |
| isLoaded(), hasLocal() | load() đã xong chưa; máy đã có bản lưu cục bộ chưa. |
| clearLocal() | Xoá bản lưu cục bộ. |
| key() | Khoá localStorage đang dùng. |
| reservedKeys | ['iapTokens', 'lives', 'livesTs'], do SDK quản lý. |
const data = await SDK.userData.load(); // { coins: 120, boosters: [1, 0, 2], iapTokens: [...] }
coins = typeof data.coins === 'number' ? data.coins : 0;
SDK.userData.set({ coins: 150 }); // gộp rồi lưu: localStorage + save_user_data
SDK.userData.update((d) => { d.boosters[0] += 1; }); // sửa tại chỗ rồi lưu
SDK.userData.batch(() => { // hai lần set, một lần save_user_data
SDK.userData.set({ coins: coins - 900 });
SDK.userData.set({ boosters });
});
SDK.userData.get().coins; // đọc không lưu
SDK.userData.hasLocal(); // false ở lần mở đầu trên máy mới
SDK.userData.clearLocal(); // khi kiểm thử lần mở đầuanalytics
| Hàm | Mô tả |
|---|---|
| logAction(name, opts?) | Gửi log_action, sự kiện tuỳ ý do game đặt tên. opts: where?, when?, level?, params?; trường bỏ trống không được gửi. |
| logLevel(payload) | Gửi log_level sau khi kiểm tra và chuẩn hoá. Trường bắt buộc và tuỳ chọn trong mục 12.3; thường dùng turn thay vì gọi trực tiếp. |
| levelStats.get(level) | { plays, fails, maxPassed, comeBack }. |
| levelStats.reset(), levelStats.raw() | Xoá bộ đếm; đọc toàn bộ { maxPassed, plays, fails }. onStart, onFail, onPass do turn tự gọi. |
| statuses | ['Start', 'Pass', 'Fail', 'Skip', 'HeartBeat']. |
| turn.begin(opts) | Bắt đầu lượt, gửi Start, trả về playTurnId. opts: level bắt buộc; difficulty? mặc định 'normal'; levelId?; preBoostersUsed?; extra? gộp thêm vào payload. |
| turn.tick(dt) | Cộng dt giây chơi thực. |
| turn.useBooster(key, n?) | Ghi nhận booster đã dùng trong lượt. n mặc định 1. |
| turn.heartbeat(extra?) | Gửi HeartBeat giữa lượt. |
| turn.end(status, extra?) | Kết thúc lượt với 'Pass', 'Fail' hoặc 'Skip'. extra: levelProgress?, score?, movesUsed?, elo?, wave?, boostersUsed?. Một lần mỗi lượt. |
| turn.abandon() | Bỏ lượt mà không gửi gì. |
| turn.isActive(), id(), level(), duration(), boosters() | Trạng thái lượt hiện tại. |
const turnId = SDK.analytics.turn.begin({ level: 12, difficulty: difficultyOf(12) }); // 'easy' | 'normal' | 'hard', game tự suy ra
SDK.analytics.turn.tick(dt); // mỗi khung hình khi đang chơi
SDK.analytics.turn.useBooster('magichand'); // tại nơi booster bị tiêu; hồi sinh cũng tính
SDK.analytics.turn.end('Fail', { levelProgress: 63 }); // hoặc 'Pass'; 'Skip' và heartbeat() chỉ khi game có cơ chế đó
SDK.analytics.logAction('rw_x2_result', { level: 12, params: { shown: true } }); // sự kiện của game, kèm màn hiện tại
SDK.analytics.levelStats.get(12); // { plays: 3, fails: 2, maxPassed: 11, comeBack: false }
SDK.analytics.logLevel({ // chỉ khi không dùng turn
status: 'Pass', currentLevel: 12, difficulty: 'hard', playTurnId: turnId,
duration: 95, levelProgress: 100, comeBackAfterFirstPass: false, failCount: 2, playCount: 3,
});ads
| Hàm | Mô tả |
|---|---|
| enabled | Module có bật không. |
| showRewarded() | true khi người chơi xem xong. |
| showInterstitial(force?) | true khi quảng cáo đã hiện. force mặc định theo ads.forceInterstitial. |
| maybeShowInterstitial(level) | Hiện interstitial nếu đủ điều kiện; true khi đã hiện. |
| config(), reconfigure() | Điều kiện đang áp dụng, đã gộp tham số URL. |
| resetCap(), lastShownAt() | Thời gian chờ giữa hai interstitial. |
| onDebug(fn) | fn(message) nhận lý do bỏ qua khi ?adsdebug=1. |
| log(message) | Ghi một dòng vào log module ads và phát ads:debug khi đang bật debug. |
const SDK = FalconSDK.init({ gameId: 'mygame', ads: { minLevel: 15, capSec: 120, chance: 0.5 } });
x2Button.onclick = async () => { // nút x2 trên popup thắng, từ màn X2_MIN_LEVEL
SDK.analytics.logAction('rw_x2_watch', { level });
const shown = await SDK.ads.showRewarded(); // true: người chơi xem xong
SDK.analytics.logAction('rw_x2_result', { level, params: { shown } });
if (shown) grantDouble();
};
continueButton.onclick = async () => {
await SDK.ads.maybeShowInterstitial(level); // tự kiểm tra ba điều kiện rồi mới hỏi app
goTo(level + 1);
};
SDK.ads.onDebug((msg) => showToast(msg)); // lý do bỏ qua, khi ?adsdebug=1
SDK.ads.config(); // { minLevel: 15, capMs: 120000, chance: 0.5, debug: false }iap
| Hàm | Mô tả |
|---|---|
| getProducts(ids?, timeoutMs?) | Danh sách sản phẩm với giá đã bản địa hoá. ids mặc định là các gói đã biết, không có thì coin_1 đến coin_6; timeoutMs mặc định iap.productsTimeoutMs. [] khi mất mạng hoặc hết giờ. |
| purchase(productId) | Mua, trả { result, purchaseToken? }. Không có timeout, một giao dịch tại một thời điểm. |
| buy(productId, opts?) | Luồng đầy đủ: analytics, màn hình chờ, mua, cấp hàng. opts: where? mặc định 'shop', level? mặc định theo shopLevel. Trả { result, granted, reason, pack, purchaseToken? }. |
| onGrant(fn) | Đăng ký nơi cộng coin. fn(pack, { productId, purchaseToken?, source, pack }), source là 'tap' hoặc 'late'. Trả về hàm huỷ. |
| onWait(fn), setWait(on) | Màn hình chờ khi đang mua. |
| refreshPrices(ids?, opts?) | Lấy giá cho các gói. opts: force? lấy lại dù đã đủ, level?. |
| setPacks(packs), getPacks(), findPack(productId) | Bảng gói. |
| grant(productId, purchaseToken?, source?) | Cấp hàng thủ công, có chống trùng. source mặc định 'tap'. |
| ledger(), isTokenPaid(token) | Sổ token đã cấp. |
| isSim(), simResult(), priceMarket() | Trạng thái của ?iap=sim và ?price=. |
| logIap(name, opts?) | Như logAction, nhưng bị tắt khi ?iap=sim. |
| pricesComplete(), resetPrices() | Đã có giá cho mọi gói chưa; buộc lấy lại ở lần mở shop kế tiếp. |
| results | ['success', 'cancelled', 'pending', 'failed']. |
SDK.iap.onGrant((pack, info) => { // đăng ký ngay sau init(), trước userData.load()
addCoins(pack.coins); // nơi duy nhất cộng coin; info.source là 'tap' hoặc 'late'
});
const r = await SDK.iap.buy('coin_3', { level }); // giao diện shop theo rules tự gọi hàm này qua window.onShopBuy
if (r.result === 'pending') showToast('Purchase pending'); // r.granted true khi đã cộng coin
const products = await SDK.iap.getProducts(['coin_1', 'coin_2']); // [] khi mất mạng: giữ giá tạm
const prices = await SDK.iap.refreshPrices(); // { prices: { coin_1: '$1.99' }, missing: [], ... }
SDK.iap.setPacks([{ productId: 'coin_1', coins: 1000, amt: '1 000', price: '$1.99' }]); // khi không có giao diện shop theo rules
SDK.iap.isTokenPaid('GPA.1234'); // token đã cấp chưashop
| Hàm | Mô tả |
|---|---|
| isEnabled() | Module có bật không. |
| open(where?) | Mở shop, ghi shop_open kèm eventWhere. where mặc định 'header_coin'. false khi shop tắt hoặc trang chưa có giao diện shop. |
| close(), isOpen() | Đóng, kiểm tra đang mở. |
| bind(opts?), isBound() | Kết nối giao diện shop với iap; init() và open() tự gọi. opts.level? là hàm trả về màn hiện tại. |
| setLevelProvider(fn) | Đổi hàm trả về màn hiện tại. |
const SDK = FalconSDK.init({ gameId: 'mygame', shop: { enabled: true }, shopLevel: () => currentLevel });
const openShop = (where: string) => {
if (!SDK.shop.open(where)) showToast('Not enough coins'); // false: shop tắt hoặc trang chưa có giao diện shop
};
coinPill.onclick = () => openShop('header_coin');
if (coins < REVIVE_PRICE) openShop('revive'); // mọi đường thiếu coin đều mở shop
if (coins < booster.price) openShop('buy_booster_' + booster.key); // 'buy_booster_addslot', ...
SDK.on('shop:open', () => header.classList.remove('over-popup'));
SDK.on('shop:buy', (r) => { if (r.result === 'failed') showToast('Purchase failed'); });removeAds
| Hàm | Mô tả |
|---|---|
| enabled | Module có bật không. |
| isRemoved | Trạng thái sở hữu hiện tại, chỉ giữ trong bộ nhớ. |
| sync() | Hỏi app trạng thái sở hữu; init() tự gọi khi autoSync. |
| getPrice() | Giá đã bản địa hoá, hoặc priceFallback. |
| buy(ctx?) | Mua kèm analytics và màn hình chờ. ctx: where? mặc định 'shop', when? mặc định 'manual', currentLevel?. Trả { result, adsRemoved, skipped? }. |
| restoreFlow(ctx?) | Khôi phục giao dịch kèm analytics và màn hình chờ. ctx.currentLevel?. Trả true khi tìm thấy giao dịch. |
| status(), purchase(ctx?), restore() | Các bước lẻ, không analytics. |
| set(owned) | Đặt trạng thái tại chỗ, phát removeAds với source: 'manual'. Chỉ dùng khi kiểm thử. |
| onChange(fn) | fn(owned, { changed, source }). Trả về hàm huỷ. |
const SDK = FalconSDK.init({ gameId: 'mygame', removeAds: { productId: 'remove_ads', priceFallback: '$4.99' } });
SDK.removeAds.onChange((owned) => { noAdsButton.hidden = owned; }); // lúc đồng bộ và khi app đẩy thay đổi
noAdsButton.onclick = async () => {
SDK.analytics.logAction('remove_ads_open', { level }); // game ghi; _start, _result, _restore do SDK ghi
noAdsPrice.textContent = await SDK.removeAds.getPrice(); // '$4.99' khi store chưa trả lời
openNoAdsPopup();
};
buyButton.onclick = async () => {
const r = await SDK.removeAds.buy({ where: 'shop', when: 'manual', currentLevel: level });
if (r.adsRemoved) closeNoAdsPopup(); // r.result: 'success' | 'cancelled' | 'pending' | 'failed'
};
restoreButton.onclick = async () => {
const found = await SDK.removeAds.restoreFlow({ currentLevel: level });
showToast(found ? 'Purchase restored' : 'Nothing to restore');
};review
| Hàm | Mô tả |
|---|---|
| show(opts?) | Mở in-app review, trả { launched, storeOpened }. opts.fallbackToStore? mặc định true. |
| openStore() | Mở trang cửa hàng. |
| askOnce(key?) | true đúng một lần cho mỗi người chơi. key mặc định review.askedKeySuffix. |
| wasAsked(key?), markAsked(key?), resetAsked(key?) | Cờ đã hỏi. |
if (level === RATE_LEVEL && SDK.review.askOnce()) { // true đúng một lần; cờ đặt trước khi hiện
SDK.analytics.logAction('rate_prompt', { level });
showRatePopup();
}
onRateSubmit(async (stars: number) => {
const positive = stars >= RATE_GATE_STARS; // ngưỡng sao do game chọn
SDK.analytics.logAction('rate_submit', { level, params: { stars, positive } });
if (positive) {
const r = await SDK.review.show(); // { launched, storeOpened }
SDK.analytics.logAction('rate_review_result', { level, params: r });
}
closeRatePopup(); // không thưởng, không hỏi lại
});
SDK.review.resetAsked(); // khi kiểm thửlives
| Hàm | Mô tả |
|---|---|
| enabled, max, count, isFull | Trạng thái. |
| consume(), add(n?), setFull() | Thay đổi số mạng, tự lưu. n mặc định 1. Trả về số mạng mới. |
| canPlay() | true khi còn mạng hoặc module tắt. |
| msToNext(), countdown(), format(ms) | Thời gian tới mạng kế tiếp, định dạng m:ss. |
| refresh() | Áp dụng mạng đã hồi, trả về số mạng. |
| regenMs(), reload() | Số mili giây hồi một mạng; đọc lại lives và livesTs từ dữ liệu người dùng. |
| startTicker(fn, opts?), stopTicker() | Gọi fn({ count, max, isFull, msToNext, countdown }) định kỳ. opts: intervalMs? mặc định 1000, keepWhenFull? mặc định false (tự dừng khi đầy). |
| onChange(fn) | fn(count) khi số mạng đổi. Trả về hàm huỷ. |
const SDK = FalconSDK.init({ gameId: 'mygame', lives: { max: 5, regenSec: 1800 } });
playButton.onclick = () => {
if (!SDK.lives.canPlay()) { showLivesPopup(); return; }
startLevel(level);
};
function showFail() { // màn không thể cứu được nữa
if (firstFail) SDK.lives.consume(); // một lần cho một lần thua thật; hồi sinh không tốn mạng
if (!SDK.lives.canPlay()) showLivesPopup();
}
watchAdForLife.onclick = async () => { // trong popup hết mạng
const shown = await SDK.ads.showRewarded();
SDK.analytics.logAction('rw_life_result', { level, params: { shown } });
if (shown) SDK.lives.add(1);
};
SDK.lives.onChange(paintHearts);
SDK.lives.startTicker((t) => { // mỗi giây; tự dừng khi đầy
hud.textContent = t.isFull ? 'Full' : `${t.count}/${t.max} ${t.countdown}`;
});
SDK.lives.count; SDK.lives.msToNext(); SDK.lives.countdown(); // 4, 1712345, '28:32'pause, util, log, codes
| Thành phần | Mô tả |
|---|---|
| pause.isPaused() | true khi shop hoặc popup che toàn màn hình đang mở. |
| pause.setOverlay(on), pause.isOverlay() | Cờ popup của game. |
| util.query(name), util.numQuery(name) | Đọc tham số URL. |
| util.lsGet(k), util.lsSet(k, v), util.lsDel(k) | localStorage có bắt lỗi. |
| log.info/warn/error(...), log.style, log.enabled, log.tag | Ghi log theo khuôn của SDK; style là auto, pretty hoặc plain. |
| codes | Bảng mã FSDK-xxx kèm ý nghĩa. |
| version, isInited(), config() | Thông tin phiên. |
| on(event, fn), off(event, fn) | Sự kiện của SDK (mục 7). on trả về hàm huỷ. |
| mock.install(opts?), mock.uninstall() | Cài và gỡ app giả. opts: state?, handlers? (mục 8). |
| mock.installed, mock.state, mock.handlers | Đang cài không; kịch bản; hàm xử lý riêng theo action. |
| mock.sent(action?), mock.log, mock.clearLog() | Message game đã gửi, lọc theo action. |
| mock.reply(action, data, ms?) | Giả lập app gửi một message, sau ms mili giây. |
function frame(ts) {
if (SDK.pause.isPaused()) { requestAnimationFrame(frame); return; } // shop hoặc popup đang mở
...
}
function showLivesPopup() { SDK.pause.setOverlay(true); livesPopup.classList.add('show'); } // popup che toàn màn hình: mạng, Remove Ads
function hideLivesPopup() { SDK.pause.setOverlay(false); livesPopup.classList.remove('show'); }
function goTo(level) { SDK.pause.setOverlay(false); ... } // dọn cờ khi chuyển màn
const level = SDK.util.numQuery('level') ?? (Number(SDK.util.lsGet('mygame_level')) || 1);
SDK.util.lsSet('mygame_level', String(level));
SDK.log.info('game', 'level loaded', { level }); // [FalconSDK] game level loaded {"level":12}
SDK.log.style = 'plain'; // một dòng văn bản, không CSS
SDK.codes['FSDK-501']; // 'shop is enabled but the overlay is not in the DOM ...'7. Sự kiện
SDK.on(name, fn) trả về hàm huỷ; SDK.off(name, fn) cũng được.
| Sự kiện | Tham số | Khi nào |
|---|---|---|
| init | config | init() xong. |
| network | isOnline | App báo đổi trạng thái mạng. |
| appState | active | App vào hoặc rời foreground. |
| userData | data | Mỗi lần lưu. |
| userData:loaded | data, source | load() xong; source là local, server hoặc empty. |
| lives | count | Số mạng đổi. |
| removeAds | owned, { changed, source } | Trạng thái Remove Ads đổi hoặc được đồng bộ. |
| iap:granted | pack, info | Đã cấp hàng cho một giao dịch. |
| iap:wait | on | Màn hình chờ bật hoặc tắt. |
| shop:open, shop:close | | Shop mở hoặc đóng. |
| shop:buy | result | Một lần mua từ shop kết thúc, kể cả cancelled, pending, failed. |
| ads:debug | message | Lý do interstitial bị bỏ qua, khi ?adsdebug=1. |
| bridge:send, bridge:message | action, data | Mọi message gửi đi và nhận về. |
const off = SDK.on('network', (isOnline) => { offlineBanner.hidden = isOnline; });
SDK.on('appState', (active) => { if (!active) pauseMusic(); });
SDK.on('userData:loaded', (data, source) => console.log('save from', source)); // 'local' | 'server' | 'empty'
SDK.on('iap:granted', (pack, info) => showToast(`+${pack.amt ?? pack.coins} coins`));
SDK.on('bridge:send', (action, data) => analyticsMirror.push({ action, data }));
off(); // thôi nghe8. Kiểm thử và gỡ lỗi
App giả
?mock=1 cài một bridge giả, dùng trên trình duyệt hoặc trong Playwright. Kịch bản điều chỉnh bằng tham số URL:
| Tham số | Tác dụng |
|---|---|
| mockPurchase=cancelled, pending, failed | Kết quả mua. |
| mockAdsRemoved=1 | App báo đã sở hữu Remove Ads. |
| mockRewarded=0, mockInterstitial=0 | Quảng cáo không hiện. |
| mockRestore=1 | Khôi phục trả về đã mua. |
| mockReview=0 | In-app review không mở được. |
| mockDelay=2000 | App trả lời chậm, tính bằng mili giây. |
| mockUserData={"coins":9} | Dữ liệu trên server của app giả, trả về cho request_user_data. |
| mockLang=vi, mockStatusBar=48 | Ngôn ngữ và chiều cao status bar app báo cho game. |
Từ mã nguồn: SDK.mock.install({ state, handlers }), SDK.mock.state, SDK.mock.sent('purchase'), SDK.mock.clearLog().
Tham số kiểm thử khác
| Tham số | Tác dụng |
|---|---|
| ?data={"coins":5} | Ghi đè dữ liệu người dùng. |
| ?adsminlevel=1&adscap=0&adschance=1&adsdebug=1 | Điều kiện interstitial và lý do bỏ qua. |
| ?iap=sim, ?iap=sim:cancelled, :pending, :failed | Giả lập cửa hàng trước khi tới bridge, tắt analytics mua hàng, hiện nhãn đỏ. |
| ?price=vn | Hiển thị giá VND qua đúng đường hiển thị; việc mua vẫn là thật. |
| ?statusBarHeight=48, ?lang=vi | Ghi đè biến môi trường. |
Log và mã lỗi
Mỗi phiên bắt đầu bằng một dòng Falcon Game Client SDK v... kèm đối tượng Config ghi cấu hình đang có hiệu lực. Các dòng sau có khuôn [FalconSDK] <module> [FSDK-xxx] <nội dung>. Mọi cảnh báo và lỗi đều có mã FSDK-xxx; docs/TROUBLESHOOTING.md giải thích từng mã, nguyên nhân và cách xử lý, cùng các triệu chứng không kèm mã. FalconSDK.codes trả về bảng mã từ mã nguồn.
Kiểm thử của package
cd sdk
npm install
npm run build # Tạo dist/ rồi nạp thử từng tệp đầu vào.
npm test # Bộ kiểm thử trên Chrome headless. Đặt CHROME=/path để chọn trình duyệt.
npm run typecheck # Kiểm tra định nghĩa kiểu bằng một tệp mẫu phía người dùng.
npm run parity # So sánh message gửi lên app giữa bundle Dice Flow gốc và rules/example.npm run parity chạy cùng một kịch bản trên bundle gốc old/Rule&Template/example.html và trên rules/example/dist/index.html (build project đó trước), với cùng một app giả: khởi động, mua gói, giao dịch trả sau, Remove Ads, đánh giá, thắng kèm x2 và interstitial, thua năm lần, hết mạng rồi xem quảng cáo. Kết quả so sánh theo từng giai đoạn trong test/parity/REPORT.md: 12 trên 13 giai đoạn giống hệt nhau, giai đoạn khởi động chỉ khác thứ tự. Danh sách điểm tích hợp bắt buộc và cách tự kiểm tra từng điểm nằm trong CHECKLIST.md.
9. Hành vi đã chốt trong SDK
Các quy tắc dưới đây đã nằm trong SDK và có kiểm thử. Game không cần và không nên làm lại.
- Bốn module tuỳ chọn tắt sẵn và chỉ bật khi
init()nhận cấu hình của chúng. Module tắt không gửi message nào cho app. - Chỉ kết quả
successmới cấp hàng. Kết quả không nhận diện được xem nhưfailed. purchase_resultkhông có ai chờ là giao dịch trả sau và vẫn được cấp hàng. SDK chống trùng theopurchaseTokenvà lưu 50 token gần nhất trongiapTokens.- Phản hồi trùng
callbackIdbị bỏ qua. Token đã cấp bị bỏ qua. Cùng sản phẩm nhưng khác token là lần mua thứ hai hợp lệ. get_productstrả rỗng là bình thường: giữ giá tạm, không chặn mở shop, thử lại ở lần mở sau.- Mỗi giao dịch chỉ tạo một lần
save_user_data, kể cả khi hàmonGrantgọiuserData.set(). - Thời gian chờ giữa hai interstitial chỉ tính quảng cáo đã hiện. SDK gửi
force: truevì điều kiện đã được kiểm tra ở phía game. - Trạng thái Remove Ads do app giữ. SDK đồng bộ lúc khởi động và khi app đẩy thay đổi. Game không chặn quảng cáo theo trạng thái này.
- Mạng hồi theo thời gian thực, không lưu số giây còn lại.
- Bộ đếm của
log_levelđược đọc trước khiPassđặt lại.durationlà số giây chơi thực. - Sau
userData.load(), SDK đẩy bản cục bộ lên app một lần mỗi phiên. Có bản cục bộ thì không gửirequest_user_data. - Trên trình duyệt thường: rewarded trả
trueđể luồng thưởng đi được, interstitial trảfalse, mua hàng trảfailed, Remove Ads không sở hữu, lưu dữ liệu trảok: true.
10. Chuyển project cũ sang SDK
Áp dụng cho project đang có native.ts riêng.
- Xoá
native.ts. Thay bằngimport FalconSDK from '@falcongames/game-client-sdk'và truyền cấu hình cho từng module game dùng. showRewarded,showInterstitialchuyển thànhSDK.ads.showRewarded,SDK.ads.showInterstitial. HàmmaybeShowInterstitialtrongmain.tschuyển thànhSDK.ads.maybeShowInterstitial(level).getProducts,purchase,setPurchaseDeliveryHandler,grantPurchase,refreshShopPrices,setIapWaitchuyển thànhSDK.iap.onGrant. Giao diện shop có sẵn trongindex.htmlgiữ nguyên; SDK tự kết nối. Nếu chưa cówindow.showIapWait, thêm màn hình chờ theorules/rule_SHOP.md.getRemoveAdsStatus,purchaseRemoveAds,restorePurchases,setRemoveAdsChangedHandlerchuyển thànhSDK.removeAds.logAction,logLevel,readLevelStatschuyển thànhSDK.analytics.beginLevelTurn,endLevelTurnchuyển thànhturn.begin,turn.end.requestData,saveDatavà phần lưu localStorage chuyển thànhSDK.userData.loadvàSDK.userData.set, vớigameIdlà tiền tố cũ (ví dụdiceflow) để giữ dữ liệu người chơi.- Các hàm lives (
refreshLives,consumeLifevà tương tự) chuyển thànhSDK.lives. KhoálivesvàlivesTsgiữ nguyên tên nên dữ liệu cũ tương thích.
11. Thành phần của package và tài liệu liên quan
| Tệp | Vai trò |
|---|---|
| dist/falcon-sdk.js | Core, chứa toàn bộ logic. Điểm vào CommonJS. |
| dist/falcon-sdk.esm.mjs | Điểm vào ESM. |
| dist/falcon-sdk.d.ts, dist/falcon-sdk.esm.d.mts | Định nghĩa kiểu cho TypeScript. |
| CHECKLIST.md | Điểm tích hợp bắt buộc và cách tự kiểm tra. |
| PUBLISHING.md | Build và đưa package lên registry. |
| docs/PROTOCOL.md | Giao thức với shell app, dành cho app team và người bảo trì SDK. |
| docs/TROUBLESHOOTING.md | Cách đọc log, bảng mã FSDK-xxx, triệu chứng thường gặp. |
Ngoài package: prepare/README.md tạo game trắng đã cài SDK và cấu hình Vite build ra một tệp; rules/ là bộ quy tắc và toàn bộ template giao diện (header, popup, mạng, booster, đánh giá, shop, màn hình chờ) cùng project mẫu rules/example/.
12. Hướng dẫn chi tiết theo module
Mục này dành cho game thương mại. Đây là bức tranh đầy đủ khi bật mọi module; từng phần được giải thích trong các mục con.
const SDK = FalconSDK.init({
gameId: 'mygame',
ads: { minLevel: 15, capSec: 120, chance: 0.5 }, // quảng cáo (12.4)
lives: { max: 5, regenSec: 1800 }, // mạng (12.7)
removeAds: { productId: 'remove_ads' }, // Remove Ads (12.6)
shop: { enabled: true }, // shop, cần giao diện shop trong trang (12.5)
shopLevel: () => currentLevel, // màn hiện tại cho analytics của shop
});
function addCoins(n) { coins += n; SDK.userData.set({ coins }); } // ví lưu qua userData (12.1)
SDK.iap.onGrant((pack) => addCoins(pack.coins)); // nơi duy nhất cộng coin khi mua (12.5)
headerCoin.onclick = () => SDK.shop.open('header_coin');
SDK.userData.load().then((data) => {
coins = data.coins ?? 0;
startLevel(readLevelFromLocalStorage() ?? 1);
SDK.bridge.gameReady();
});
SDK.analytics.turn.begin({ level, difficulty: 'normal' }); // vào màn (12.3)
if (!SDK.pause.isPaused() && !document.hidden) SDK.analytics.turn.tick(dt); // trong vòng render (12.9)
SDK.analytics.turn.end('Pass'); // thắng
SDK.analytics.turn.end('Fail', { levelProgress: percent }); // thua thật
SDK.lives.consume();
await SDK.ads.maybeShowInterstitial(level); // Continue sau khi thắng (12.4)
goTo(level + 1);
SDK.removeAds.onChange((owned) => { noAdsButton.hidden = owned; }); // (12.6)
noAdsBuy.onclick = () => SDK.removeAds.buy({ where: 'shop', when: 'manual', currentLevel: level });12.1 Dữ liệu người dùng (userData)
Dữ liệu người dùng là một đối tượng JSON nhỏ chứa những gì người chơi sở hữu hoặc đã trả tiền: coin, booster, cài đặt. Quy ước: ví coin và booster nằm trong dữ liệu người dùng; tiến độ màn chơi, cờ một lần và bộ đếm analytics nằm trong localStorage; trạng thái Remove Ads không lưu ở đâu cả, app giữ.
Cách lưu hoạt động:
- SDK giữ một bản trong localStorage (
<gameId>_save) và gửi cùng nội dung lên app bằngsave_user_data. Mỗi lần lưu gửi toàn bộ đối tượng dưới dạng chuỗi, không gửi phần thay đổi. load()đọc?data=, rồi localStorage. Khi đã có bản cục bộ, SDK không hỏi server: bản cục bộ là nguồn chuẩn, bản trên server là bản dự phòng cho máy mới hoặc khi cài lại. Chỉ khi không có gì cục bộ, SDK mới gửirequest_user_data, chờ tối đaserverTimeoutMs, rồi gộp kết quả. Giá trị của server ghi đè giá trị cục bộ; riêngiapTokensđược hợp nhất để token vừa ghi trong phiên không bị bản cũ trên server xoá mất.- Khi
load()xong, SDK đẩy bản hiện tại lên app đúng một lần mỗi phiên (syncOnLoad). Việc này sửa những lần lưu thất bại khi mất mạng và tạo bản ghi trên server ở lần mở đầu tiên. - Lưu thất bại (
FSDK-201) hoặc app không trả lời (FSDK-103) không chặn game: localStorage đã có bản mới, lần lưu kế tiếp gửi lại toàn bộ.
Cách dùng trong game:
const save = () => SDK.userData.set({
coins: state.coins,
boosters: state.boosters.slice(),
sound: settings.sound ? 1 : 0,
});
SDK.userData.load().then((d) => {
state.coins = typeof d.coins === 'number' ? d.coins : 0; // kiểm tra kiểu: bản lưu có thể cũ hơn mã
if (Array.isArray(d.boosters)) state.boosters = d.boosters.map(Number);
goTo(Number(localStorage.getItem('mygame_level')) || 1); // màn chơi đọc từ localStorage, không từ d
SDK.bridge.gameReady();
});- Gọi
save()tại mọi nơi ví thay đổi: cộng hoặc trừ coin, dùng, mua hoặc được tặng booster, đổi cài đặt. Chuyển màn không phải là lúc lưu. - Không gọi
save()mỗi khung hình. Khi một thao tác đổi nhiều thứ, bọc trongbatch()để chỉ gửi một lần. - Không sửa đối tượng trả về từ
get()mà quên gọiset()hoặcsave(): không có gì được gửi. - Không ghi vào
iapTokens,lives,livesTs. SDK tự lưu khi số mạng đổi và khi ghi token mua hàng. - Không đổi
gameIdcủa game đã phát hành: khoá lưu trữ đổi theo và người chơi mất dữ liệu. Khi game cũ lưu một giá trị ở khoá hoặc định dạng khác, đọc nó một lần lúc khởi động, ghi lại quaset().
Kiểm tra với ?mock=1: lần mở đầu (sau clearLocal() hoặc xoá dữ liệu trang) log có request_user_data, rồi một save_user_data, rồi game_ready; các lần mở sau không còn request_user_data. ?mock=1&mockUserData={"coins":9999} trên máy chưa có bản lưu cho 9999 coin; ?data={"coins":5} ghi đè mọi nguồn.
12.2 Sự kiện tuỳ ý (log_action)
log_action là sự kiện analytics tuỳ ý: game đặt tên sự kiện và dữ liệu kèm theo, app chuyển nguyên trạng lên hệ thống analytics, không kiểm tra lược đồ. Dùng cho mọi thứ không phải vòng đời màn chơi: xem quảng cáo, mở popup, chọn sao, hướng dẫn, phần thưởng ngày, cài đặt. Vòng đời màn chơi có lược đồ riêng, log_level (12.3). SDK không giới hạn tên sự kiện; studio thống nhất một tập tên trong bảng cuối mục này.
API
logAction(name, opts?) gửi một message log_action. Chỉ name bắt buộc; mỗi trường của opts chỉ được gửi khi có giá trị.
| Tham số | Bắt buộc | Trường trên message | Ý nghĩa |
|---|---|---|---|
| name | có | eventName | Tên sự kiện, chữ thường, nối bằng gạch dưới. |
| opts.level | không | currentLevel | Màn hiện tại. Mọi sự kiện của game đều nên có. |
| opts.where | không | eventWhere | Nơi phát sinh, ví dụ nguồn mở shop. |
| opts.when | không | eventWhen | Thời điểm hoặc kết quả, ví dụ success. |
| opts.params | không | params | Đối tượng dữ liệu kèm theo. Bỏ qua khi rỗng. |
const act = (name: string, params?: Record<string, unknown>) => // tiện ích nên có trong mọi game: luôn kèm màn hiện tại
SDK.analytics.logAction(name, { level: currentLevel, params });Quy tắc:
- Tên chữ thường, nối bằng gạch dưới, bắt đầu bằng tên nhóm:
rate_submit,tutorial_step,daily_reward_claim. - Luôn kèm
level. Analytics gần như luôn cắt theo màn. paramsphẳng và nhỏ: số, chuỗi, boolean. Không gửi cả đối tượng trạng thái game.- Ghi tại thời điểm xảy ra, một lần cho một lần xảy ra. Không ghi trong vòng render, không ghi theo chu kỳ.
- Không ghi lại các sự kiện SDK đã ghi (bảng cuối mục): mở shop, mua hàng, interstitial, Remove Ads.
- Sự kiện liên quan tới store đi qua
iap.logIap()để bị tắt cùng?iap=sim; sự kiện khác đi qualogAction().
Trường hợp sử dụng
Xem quảng cáo có thưởng, ghi trước và sau để đo tỉ lệ bỏ dở:
x2Button.onclick = async () => {
act('rw_x2_watch');
const shown = await SDK.ads.showRewarded();
act('rw_x2_result', { shown });
// → log_action { eventName: 'rw_x2_watch', currentLevel: 20 }
// → log_action { eventName: 'rw_x2_result', currentLevel: 20, params: { shown: true } }
if (shown) grantDouble();
};Popup đánh giá, ghi cả ba bước để biết bao nhiêu người được hỏi, chọn mấy sao, và in-app review có mở được không:
if (level === RATE_LEVEL && SDK.review.askOnce()) { act('rate_prompt'); showRatePopup(); }
onRateSubmit(async (stars) => {
const positive = stars >= RATE_GATE_STARS;
act('rate_submit', { stars, positive });
if (positive) act('rate_review_result', await SDK.review.show()); // { launched, storeOpened }
closeRatePopup();
});Hướng dẫn chơi, một sự kiện cho mỗi bước và một khi xong, để thấy bước nào làm người chơi bỏ cuộc:
function showTutorialStep(step: number) { act('tutorial_step', { step }); ... }
function finishTutorial() { act('tutorial_done'); }
// → log_action { eventName: 'tutorial_step', currentLevel: 1, params: { step: 2 } }Nguồn mở shop khi không dùng giao diện shop của rules: dùng where thay vì params, vì eventWhere là trường chuẩn cho "từ đâu":
SDK.analytics.logAction('shop_open', { level, where: 'revive' });
// → log_action { eventName: 'shop_open', currentLevel: 12, eventWhere: 'revive' }
// Với giao diện shop theo rules, SDK tự ghi sự kiện này khi gọi shop.open(where).Kết quả của một thao tác, dùng when cho kết quả và params cho chi tiết:
SDK.analytics.logAction('daily_reward_claim', { level, when: 'success', params: { day: 3, coins: 200 } });
SDK.analytics.logAction('setting_toggle', { level, params: { key: 'music', on: false } });Trong gameplay, log_action ghi những quyết định của người chơi mà log_level không có chỗ: dùng tiền, mua hoặc mở khoá, bỏ cuộc, dùng trợ giúp. Riêng booster dùng trong màn không ghi bằng log_action: gọi turn.useBooster(key) và nó đi vào boostersUsed của log_level (12.3).
Hồi sinh bằng coin trên popup thua, ghi giá và lần thứ mấy để biết người chơi chịu trả tới đâu:
reviveButton.onclick = () => {
if (!spend(REVIVE_PRICE, 'revive')) return; // thiếu coin: spend() đã mở shop
act('revive', { price: REVIVE_PRICE, revives: state.revives + 1 });
// → log_action { eventName: 'revive', currentLevel: 18, params: { price: 900, revives: 1 } }
engine.revive();
};Mua và mở khoá booster, hai sự kiện khác nhau vì một cái tiêu coin, một cái là mốc tiến độ:
function buyBooster(b: BoosterDef) {
if (!spend(b.price, 'buy_booster_' + b.key)) return;
act('booster_buy', { key: b.key, price: b.price, qty: b.qty });
// → log_action { eventName: 'booster_buy', currentLevel: 9, params: { key: 'magichand', price: 1500, qty: 3 } }
}
function unlockBooster(b: BoosterDef) { // lần đầu tới màn mở khoá
act('booster_unlock', { key: b.key });
}Bỏ cuộc giữa màn, ghi tiến độ lúc bỏ để thấy người chơi thường bỏ ở đâu; log_level chỉ nhận Fail khi màn thật sự kết thúc:
quitButton.onclick = () => {
act('level_quit', { progress: Math.round(progress()) });
// → log_action { eventName: 'level_quit', currentLevel: 23, params: { progress: 41 } }
SDK.analytics.turn.abandon(); // không gửi Fail
goToMenu();
};
restartButton.onclick = () => { act('level_restart'); goTo(state.level); };Trợ giúp có giới hạn, ghi số còn lại:
hintButton.onclick = () => {
if (!engine.showHint()) return;
act('hint_use', { remaining: state.hints - 1 });
// → log_action { eventName: 'hint_use', currentLevel: 7, params: { remaining: 2 } }
};Mốc và chuỗi, một lần cho mỗi lần đạt:
if (state.winStreak === 5) act('win_streak', { count: 5 });
if (state.level === 1 && firstEverWin) act('first_win');Kiểm tra với ?mock=1: FalconSDK.mock.sent('log_action').map((m) => m.data) liệt kê mọi sự kiện đã gửi trong phiên.
Danh mục sự kiện
Sự kiện game ghi, theo quy ước của studio (rules/rule_ADS.md):
| Sự kiện | Khi nào | params |
|---|---|---|
| rw_x2_watch | Bấm nút x2 trên popup thắng, trước khi gọi ads.showRewarded(). | |
| rw_x2_result | Sau khi rewarded x2 đóng. | { shown } |
| rw_life_result | Sau khi rewarded trong popup hết mạng đóng. | { shown } |
| remove_ads_open | Mở popup Remove Ads. | |
| rate_prompt | Hiện popup đánh giá, ngay sau review.askOnce() trả true. | |
| rate_submit | Người chơi chọn sao. | { stars, positive } |
| rate_review_result | Sau review.show(), chỉ khi positive. | { launched, storeOpened } |
Sự kiện SDK tự ghi, game không ghi lại:
| Sự kiện | Khi nào | eventWhere | eventWhen | params |
|---|---|---|---|---|
| interstitial_request, interstitial_shown, interstitial_no_fill | ads.maybeShowInterstitial() qua điều kiện, rồi theo câu trả lời của app. | | | |
| shop_open | shop.open(where). | where đã truyền: header_coin, revive, buy_booster_<key> | | |
| shop_products_unavailable, shop_products_partial | Store không trả giá, hoặc thiếu một phần. | shop | | { missing } khi thiếu một phần |
| shop_purchase_start, shop_purchase_result | iap.buy(). | shop | kết quả, ở _result | { productId, coins, price }, { productId, coins } |
| shop_purchase_delivered | Giao dịch trả sau được cấp. | native | | { productId } |
| remove_ads_start, remove_ads_result, remove_ads_restore | removeAds.buy(), removeAds.restoreFlow(). | | kết quả, ở _result | |
Các sự kiện mua hàng của SDK đi qua iap.logIap(), bị tắt khi ?iap=sim; sự kiện quảng cáo và sự kiện của game đi qua logAction(), không bị tắt. currentLevel của sự kiện SDK lấy từ shopLevel (shop) hoặc level, currentLevel truyền vào hàm (quảng cáo, Remove Ads).
12.3 log_level và lượt chơi (analytics.turn)
log_level ghi vòng đời một màn chơi với lược đồ cố định. Sự kiện tuỳ ý dùng log_action (12.2). Khi dùng turn, game chỉ cung cấp các trường ở cột "Game"; phần còn lại SDK tự điền.
| Trường | Bắt buộc | Nguồn khi dùng turn | Ghi chú |
|---|---|---|---|
| status | có | SDK | Start, Pass, Fail, Skip, HeartBeat. |
| currentLevel | có | Game, qua turn.begin({ level }) | Số màn. |
| difficulty | có | Game, qua turn.begin({ difficulty }) | Mặc định 'normal' nếu không truyền. |
| playTurnId | có | SDK | Một mã cho mỗi lượt, giống nhau ở Start và lúc kết thúc. |
| duration | có | SDK, từ turn.tick(dt) | Số giây chơi thực, làm tròn xuống. |
| levelProgress | có | Game, qua turn.end('Fail', { levelProgress }) | 0 đến 100. Pass luôn gửi 100, Start gửi 0. |
| comeBackAfterFirstPass, failCount, playCount | có | SDK, từ <gameId>_level_stats | Bộ đếm theo màn. |
| currentLevelId | không | Game, qua turn.begin({ levelId }) | Mã màn khi khác số màn. |
| score, movesUsed, elo, wave | không | Game, qua turn.end(status, extra) | Chỉ gửi khi là số. |
| boostersUsed | không | SDK, từ turn.useBooster(key) | Chỉ gửi khi có booster. |
| preBoostersUsed | không | Game, qua turn.begin({ preBoostersUsed }) | Booster chọn trước khi vào màn. |
Trợ giúp theo lượt làm toàn bộ việc này:
SDK.analytics.turn.begin({ level, difficulty: difficultyOf(level) }); // gửi Start, tăng playCount
SDK.analytics.turn.useBooster('addslot'); // tại nơi booster bị tiêu; hồi sinh cũng tính
SDK.analytics.turn.end('Pass'); // levelProgress 100, đặt lại bộ đếm của màn
SDK.analytics.turn.end('Fail', { levelProgress: percent }); // khi màn không thể cứu được nữatick(dt)chỉ gọi khi đang chơi thật: không tạm dừng, không ẩn tab, app ở foreground.durationlà tổng của cácdtnày, không tính từDate.now(). Kẹpdttrong vòng render để một khung hình bị treo không cộng nhiều giây.Failphát khi màn kết thúc thật, không phải ở lần thua đầu khi còn hồi sinh. Một lượt chỉend()một lần; lần gọi sau bị bỏ qua.difficultyvàlevelProgresslà quyết định của từng game. Bộ đếmfailCount,playCount,comeBackAfterFirstPassdo SDK giữ trong<gameId>_level_statsvà đọc trước khiPassđặt lại.- Các sự kiện rời quanh màn chơi (xem quảng cáo, mở shop, đánh giá) là
log_action, mục 12.2; SDK tự ghi phần của mình.
12.4 Quảng cáo (ads)
Bật bằng ads: { minLevel, capSec, chance }. App sở hữu SDK quảng cáo; game chỉ yêu cầu hiện và nhận kết quả.
Rewarded, ví dụ x2 thưởng khi thắng:
SDK.analytics.logAction('rw_x2_watch', { level });
const shown = await SDK.ads.showRewarded(); // true: xem xong, cấp thưởng
SDK.analytics.logAction('rw_x2_result', { level, params: { shown } });
if (shown) grantX2();Hai sự kiện rw_x2_watch và rw_x2_result (cũng như rw_life_result của popup hết mạng) do game ghi; ba sự kiện interstitial do SDK ghi.
Interstitial qua bộ điều kiện:
onContinue(async () => {
await SDK.ads.maybeShowInterstitial(level); // tự kiểm tra ba điều kiện rồi hỏi app
goTo(level + 1);
});maybeShowInterstitial(level) kiểm tra lần lượt: màn không nhỏ hơn minLevel; đã qua capSec giây kể từ interstitial gần nhất đã hiện; tung xúc xắc theo chance. Đủ cả ba thì gửi show_interstitial với force: true và ghi interstitial_request, rồi interstitial_shown hoặc interstitial_no_fill theo câu trả lời. Mỗi lần bỏ qua có một dòng log của module ads nêu lý do.
- Gọi tại các điểm chuyển màn: Continue sau khi thắng và các đường chơi lại. Không gọi khi thoát game.
- Không chặn interstitial theo
removeAds.isRemoved: app tự chặn và trảshown: false, để hai bên không lệch trạng thái. - Không thêm timeout quanh
showRewarded(): app luôn trả lời khi quảng cáo đóng. - Trên trình duyệt thường, rewarded trả
true(rewardedInBrowser) để luồng thưởng đi được, interstitial trảfalse(interstitialInBrowser).
Kiểm tra: ?mock=1&adsminlevel=1&adscap=0&adschance=1&adsdebug=1 làm interstitial hiện ở mọi lần chuyển màn và phát ads:debug với từng lý do; window.__adsForce = { minLevel, capSec, chance } đặt từ một thẻ script trước bundle cho bản test. SDK.ads.onDebug(showToast) đưa lý do lên màn hình thiết bị.
12.5 Packs, shop và mua hàng (iap, shop)
Mua hàng gồm ba phần: bảng gói (packs), luồng mua (iap) và giao diện shop (shop). SDK không có giao diện; giao diện shop và màn hình chờ là hai template của bộ rules (rules/rule_SHOP.md).
Bảng gói. Mỗi gói là một đối tượng với các trường:
| Trường | Bắt buộc | Ý nghĩa |
|---|---|---|
| productId | có | Mã sản phẩm trên store, ví dụ coin_1. |
| coins | có | Số coin cấp khi mua thành công. Giá trị chuẩn cho onGrant. |
| amt | không | Nhãn hiển thị, ví dụ '1 000'. |
| price | không | Giá tạm cho tới khi store trả giá thật. |
| trường khác | không | Giao diện shop có thể thêm trường riêng (sprite, kích thước); SDK giữ nguyên và chuyển cho onGrant. |
Nguồn của bảng:
- Giao diện shop theo rules định nghĩa
window.shopPacks(PACKS[]trong template).shop.bind()đọc nó và đó là nguồn chuẩn;packstruyền vàoinit()lúc này bị bỏ qua kèmFSDK-502. - Game không dùng giao diện shop của rules thì truyền
packsvàoinit()hoặc gọiiap.setPacks(). - Không có bảng gói thì mọi giao dịch thành công bị ghi
FSDK-401và không cấp hàng. Game bán hàng phải có bảng gói trước khi mở shop.
Luồng mua. Đăng ký nơi cộng coin ngay sau init(), trước userData.load(), vì giao dịch trả sau có thể tới ngay lúc khởi động:
SDK.iap.onGrant((pack, info) => {
addCoins(pack.coins); // nơi duy nhất cộng coin; info.source là 'tap' hoặc 'late'
showToast('+' + (pack.amt ?? pack.coins) + ' coins');
});
SDK.on('shop:buy', (r) => {
if (r.result === 'pending') showToast('Purchase pending');
else if (r.result === 'failed') showToast('Purchase failed'); // 'cancelled': im lặng
});iap.buy(productId, { where, level }) làm trọn một lần mua: ghi shop_purchase_start, bật màn hình chờ, gửi purchase, chờ purchase_result không giới hạn thời gian, tắt màn hình chờ, ghi shop_purchase_result, và khi success thì ghi token vào iapTokens rồi gọi onGrant. Giao diện shop theo rules gọi hàm này qua window.onShopBuy do shop.bind() cài; game không gọi trực tiếp.
Giao dịch trả sau: một purchase_result không có ai chờ (thanh toán pending hoàn tất, WebView bị tắt trước khi app trả lời, app quét lúc mở) vẫn được cấp hàng qua cùng onGrant, với info.source là 'late', và ghi shop_purchase_delivered. SDK đã chống trùng theo purchaseToken, nên onGrant là một lần cộng coin đơn giản, không điều kiện. Đăng ký onGrant muộn khi giao dịch trả sau tới là lỗi FSDK-403: token đã ghi, coin không được cộng.
Giao diện shop. shop.bind() kết nối với trang qua các hàm toàn cục mà template định nghĩa: window.openShop(), window.closeShop(), window.shopPacks, window.shopSetPrices(byId), window.shopSetPricing(on); SDK tự định nghĩa window.onShopOpen và window.onShopBuy, game không định nghĩa hai hàm này. init() gọi bind() khi window.openShop đã có, hoặc ngay sau khi DOM nạp xong; shop.open() gọi lại khi cần. Màn hình chờ là window.showIapWait(on); SDK gọi nó quanh mọi lần mua, kể cả Remove Ads và khôi phục. Nút đóng của màn hình chờ sau 30 giây chỉ ẩn màn hình, không huỷ giao dịch: success tới muộn vẫn được cấp.
Giá: mỗi lần mở shop, SDK gọi refreshPrices() cho tới khi mọi gói có giá rồi giữ lại; store không trả lời trong productsTimeoutMs hoặc trả [] thì giữ giá tạm, thẻ vẫn bấm được, ghi shop_products_unavailable; thiếu một phần thì ghi shop_products_partial kèm mã thiếu.
Mở shop: shop.open(where) ghi shop_open với eventWhere rồi mở giao diện. Mọi đường thiếu coin đều gọi shop.open('<where>') thay vì chỉ hiện thông báo; chỉ khi hàm trả false mới hiện thông báo. Khi shop mở, pause.isPaused() trả true; vòng render bỏ qua khung hình.
Kiểm tra: ?mock=1 mua thành công; &mockPurchase=pending không cộng coin và có thông báo pending; gọi window.onNativeMessage('purchase_result', { productId, result: 'success', purchaseToken: 'late_1' }) từ console mô phỏng giao dịch trả sau; gửi cùng token lần hai không cộng thêm. ?iap=sim giả lập cửa hàng trước khi tới bridge, ?price=vn xem độ rộng giá VND.
12.6 Remove Ads (removeAds)
Bật bằng removeAds: { productId, priceFallback }. Gói Remove Ads là sản phẩm không tiêu hao do Google Play và app quản lý; game chỉ phản chiếu trạng thái ra giao diện.
SDK.removeAds.onChange((owned) => { noAdsButton.hidden = owned; }); // gọi lúc đồng bộ và khi app đẩy thay đổi
noAdsButton.onclick = () => {
SDK.analytics.logAction('remove_ads_open', { level }); // sự kiện mở popup do game ghi
SDK.removeAds.getPrice().then((price) => { noAdsPrice.textContent = price; });
openNoAdsPopup();
};
noAdsBuy.onclick = () => SDK.removeAds.buy({ where: 'shop', when: 'manual', currentLevel: level });
noAdsRestore.onclick = () => SDK.removeAds.restoreFlow({ currentLevel: level });- Lúc khởi động,
autoSyncgửiget_remove_ads_statusvà SDK ngheremove_ads_changedsuốt phiên (hoàn tiền, mua trên máy khác).isRemovedchỉ ở trong bộ nhớ. buy()ghiremove_ads_startvàremove_ads_result, bật màn hình chờ, và trả{ result, adsRemoved }. Khi đã sở hữu,buy()trả ngay{ result: 'success', skipped: true }.restoreFlow()là nút Google yêu cầu; ghiremove_ads_restore, trảtruekhi tìm thấy giao dịch.- Không lưu trạng thái vào dữ liệu người dùng, không chặn quảng cáo phía game theo trạng thái này.
Kiểm tra: ?mock=1&mockAdsRemoved=1 ẩn nút ngay từ đầu; ?mock=1&mockRestore=1 làm khôi phục thành công.
12.7 Mạng (lives)
Bật bằng lives: { max, regenSec }. Số mạng và mốc thời gian nằm trong dữ liệu người dùng (lives, livesTs) nên hồi được cả khi app tắt và theo người chơi sang máy mới.
if (!SDK.lives.canPlay()) { showLivesPopup(); return; } // trước khi vào màn
if (firstFail) SDK.lives.consume(); // trong showFail(): một lần cho một lần thua thật; hồi sinh không tốn mạng
const shown = await SDK.ads.showRewarded(); // nút xem quảng cáo trong popup hết mạng
SDK.analytics.logAction('rw_life_result', { level, params: { shown } });
if (shown) SDK.lives.add(1);
SDK.lives.startTicker((t) => { hud.textContent = t.isFull ? 'Full' : t.countdown; }); // 'm:ss', tự dừng khi đầy
SDK.lives.onChange(paintHearts);- Mạng hồi theo thời gian thực: cứ
regenSecgiây được một mạng, phần dư giữ lại cho mạng kế tiếp; đầy thì dừng đếm. Đồng hồ máy lùi không sinh mạng. - Mỗi thay đổi tự lưu qua
userData; game không ghilivesvàlivesTs. - Game không có mạng thì không truyền
lives; module tắt,canPlay()luôntrue.
12.8 Đánh giá ứng dụng (review)
if (level === RATE_LEVEL && SDK.review.askOnce()) { // cờ đặt trước khi hiện, không hỏi lại
SDK.analytics.logAction('rate_prompt', { level });
showRatePopup();
}
onRateSubmit(async (stars) => {
const positive = stars >= RATE_GATE_STARS; // game chọn ngưỡng; 1 là an toàn về chính sách
SDK.analytics.logAction('rate_submit', { level, params: { stars, positive } });
if (positive) {
const r = await SDK.review.show({ fallbackToStore: true }); // { launched, storeOpened }
SDK.analytics.logAction('rate_review_result', { level, params: r });
}
closeRatePopup();
});askOnce() trả true đúng một lần cho mỗi người chơi (khoá <gameId>_rate_asked), kể cả khi người chơi đóng popup mà không đánh giá. Không thưởng coin hay mạng cho việc đánh giá. resetAsked() dùng khi kiểm thử. Trên trình duyệt thường, show() trả { launched: false, storeOpened: false }; ?mock=1&mockReview=0 mô phỏng app không mở được in-app review.
12.9 Tạm dừng và popup (pause)
Vòng render bỏ qua khung hình khi pause.isPaused() trả true, để các nút của shop và popup phản hồi tức thì và turn.tick() không đếm thời gian đó:
function frame(ts) {
const dt = Math.min((ts - last) / 1000, 1 / 20); last = ts;
if (SDK.pause.isPaused()) { requestAnimationFrame(frame); return; }
if (!document.hidden && SDK.bridge.env.appActive) SDK.analytics.turn.tick(dt);
update(dt); render();
requestAnimationFrame(frame);
}Shop đặt cờ window.__shopOpen khi mở. Popup che toàn màn hình của game gọi pause.setOverlay(true) khi mở, setOverlay(false) khi đóng và trong bước dọn dẹp lúc chuyển màn, để một lần chuyển màn không bỏ sót cờ.
12.10 Bridge và biến môi trường (bridge)
Game hiếm khi gọi bridge trực tiếp; các module đã bọc mọi action trong docs/PROTOCOL.md. Khi cần một action chưa có module:
SDK.bridge.send('custom_action', { a: 1 }); // một chiều
const r = await SDK.bridge.request('custom_query', {}, { timeoutMs: 5000, timeoutReply: null, browserReply: { ok: true } });
const off = SDK.bridge.on('network_changed', (d) => { ... }); // message từ app; off() để huỷrequest() tạo callbackId, gửi kèm data, và giải quyết khi app trả lời cùng callbackId. Quá timeoutMs thì trả timeoutReply kèm cảnh báo FSDK-103; không có timeoutMs thì chờ không giới hạn. Trên trình duyệt thường, trả browserReply ngay.
bridge.env gồm language, statusBarHeight, statusBarHeightPx, isOnline, appActive, đọc từ các biến app đặt lên window và cập nhật khi app đẩy network_changed và app_state_changed. init() đặt biến CSS --sbh theo chiều cao status bar; header dùng calc(var(--sbh, 0px) + ...). ?statusBarHeight=48 và ?lang=vi ghi đè khi thử trên trình duyệt.
Một action app chưa hỗ trợ không được xử lý bằng cách sửa SDK trong game: hỏi app team và người bảo trì SDK để bổ sung vào package.
