mcpField
M
msn-js-sdk

msn-js-sdk

github.com/hungify开发016 次安装1 周前更新
代码仓库

关于 msn-js-sdk

msn-js-sdk 是 Microsoft Start 游戏 JavaScript SDK 的集成指南,介绍玩家认证、云存档、广告、分享、排行榜提交和应用内购买。它面向该平台的游戏开发者,并说明授权条件、账户类型限制及 API 的使用约束。

主要功能

  1. 01加载并轮询 SDK 全局对象
  2. 02检查玩家登录与授权状态
  3. 03读写云存档数据
  4. 04展示横幅、插页式和奖励广告
  5. 05分享内容并提交排行榜分数
  6. 06查询目录、购买及管理应用内商品

适用场景

  1. 01为登录玩家保存云端游戏数据
  2. 02在游戏中展示不同类型的广告
  3. 03提交游戏结果供平台排行榜显示
  4. 04为符合条件的账户处理应用内购买

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/hungify/playables-skills --skill msn-js-sdk

安装后在 Claude Code 中输入/msn-js-sdk确认已加载。

详细介绍

MSN JavaScript SDK

概述

Microsoft Start 游戏会在官方 msstart 脚本加载后暴露一个全局对象 window.$msstart。调用功能时使用 window.$msstart.<method>Async(...),并返回一个 Promise。

关键限制:msstart 脚本加载完成并不保证 window.$msstart 已就绪——调用任何 API 前,都要轮询该全局对象。

关键限制:仅当 userAccountType === 'personal' 时,才能使用 IAP。工作或学校账户无法购买附加内容。

msstart 未提供:邀请好友、加入社区、创建帖子、添加到主屏幕、评分、成就、远程配置、服务器时间、生命周期/暂停/音频事件——暂停和音频功能请使用标准浏览器的 visibilitychange/blur/focus 事件;非权威计时请使用 Date.now()。

集成工作流

  1. 加载脚本——注入 <script src='https://assets.msn.com/staticsb/statics/latest/msstart-games-sdk/msstart-v1.0.0-rc.21.min.js'>(或动态注入),并等待 window.$msstart 定义。
  2. 验证 $msstart——调用任何 API 前,msstart = window.$msstart 必须为真值。
  3. 探测身份验证——调用 getSignedInUserAsync();如果调用成功,设置 isPlayerAuthorized = true,并获取 playerId。访客用户的错误可静默捕获。
  4. 限制功能使用——云存档和 IAP 需要 isPlayerAuthorized。IAP 还需要 userAccountType === 'personal'。
  5. 显示首个插屏广告——初始化后至少等待 60 秒,再显示首个插屏广告。
  6. 验证云存档——依赖云存档前,先执行一次 getDataAsync 往返测试。

安装

加载带版本号的 CDN 脚本,并固定版本:

<script src="https://assets.msn.com/staticsb/statics/latest/msstart-games-sdk/msstart-v1.0.0-rc.21.min.js"></script>

如需动态注入,请使用 document.createElement('script') 并设置 onload/onerror 回调,然后在 setTimeout 循环中轮询 window.$msstart,直到其定义或超时。

初始化

const SDK_URL =
    'https://assets.msn.com/staticsb/statics/latest/msstart-games-sdk/msstart-v1.0.0-rc.21.min.js';

const options = {
    gameId: 'YOUR_MSN_GAME_ID',
    payments: [
        // { id: 'gem_pack_small', msn: { id: 'msstore-product-id' } }
    ]
};

let msstart = null;
let isInitialized = false;
let isPlayerAuthorized = false;
let playerId = null;
let playerName = null;
let playerExtra = null;
let isPaymentsSupported = false;

function updatePlayerInfo(data) {
    if (data) {
        isPlayerAuthorized = true;
        playerId = data.playerId;
        playerName = data.playerDisplayName;
        playerExtra = data;
        isPaymentsSupported = data.userAccountType.toLowerCase() === 'personal';
    } else {
        isPlayerAuthorized = false;
        playerId = null;
        playerName = null;
        playerExtra = null;
        isPaymentsSupported = false;
    }
}

function initialize() {
    if (isInitialized) return Promise.resolve();

    return addJavaScript(SDK_URL)
        .then(() => waitFor('$msstart'))
        .then(() => {
            msstart = window.$msstart;
            return msstart
                .getSignedInUserAsync()
                .then((data) => updatePlayerInfo(data))
                .catch(() => updatePlayerInfo(null));
        })
        .then(() => {
            isInitialized = true;
        });
}

玩家身份验证

function authorizePlayer() {
    if (isPlayerAuthorized) return Promise.resolve();

    return msstart
        .signInAsync()
        .then((data) => updatePlayerInfo(data))
        .catch((error) => {
            updatePlayerInfo(null);
            throw error;
        });
}

已登录用户对象包含 playerId、playerDisplayName 和 userAccountType(消费者账户的值为 'personal')。

云存档

$msstart.cloudSave 下按游戏划分的键值存储区,作用域由 gameId 确定。没有按键访问的端点——读取整个对象一次,每次保存时写入合并后的对象。删除数据时,将目标键的值设为 null 并写入。需要已授权的玩家;访客用户请回退到 localStorage。

let cachedData = null;

function isCloudSaveAvailable() {
    return isPlayerAuthorized;
}

function getStorageData(key) {
    if (!isPlayerAuthorized) return Promise.reject();

    const loadCache = cachedData
        ? Promise.resolve(cachedData)
        : msstart.cloudSave.getDataAsync({ gameId: options.gameId }).then((data) => {
              cachedData = data ?? {};
              return cachedData;
          });

    return loadCache.then((data) => (Array.isArray(key) ? key.map((k) => data[k]) : data[key]));
}

function setStorageData(key, value) {
    if (!isPlayerAuthorized) return Promise.reject();

    const data = cachedData ? { ...cachedData } : {};
    if (Array.isArray(key)) {
        key.forEach((k, i) => {
            data[k] = value[i];
        });
    } else {
        data[key] = value;
    }

    return msstart.cloudSave.saveDataAsync({ data, gameId: options.gameId }).then(() => {
        cachedData = data;
    });
}

function deleteStorageData(key) {
    if (!isPlayerAuthorized) return Promise.reject();

    const payload = {};
    const keys = Array.isArray(key) ? key : [key];
    keys.forEach((k) => {
        payload[k] = null;
        if (cachedData) delete cachedData[k];
    });

    return msstart.cloudSave.saveDataAsync({ data: payload, gameId: options.gameId });
}

广告

展示横幅广告

将展示位置字符串数组('position:WxH')传入 showDisplayAdsAsync。建议同时展示的广告位最多为 2 个。使用 hideDisplayAdsAsync() 隐藏所有广告。

允许的位置和尺寸:

const MSN_SIZES_BY_POSITION = {
    top: [
        [728, 90],
        [970, 250],
        [320, 50]
    ],
    bottom: [[320, 50]],
    left: [
        [300, 250],
        [300, 600],
        [320, 50],
        [160, 600]
    ],
    right: [
        [300, 250],
        [300, 600],
        [320, 50],
        [160, 600]
    ],
    topleft: [[300, 250]],
    topright: [[300, 250]],
    bottomleft: [[300, 250]],
    bottomright: [[300, 250]]
};

简单用法:

msstart.showDisplayAdsAsync(['top:728x90']);
msstart.showDisplayAdsAsync(['bottom:320x50']);
msstart.hideDisplayAdsAsync();

要为给定的像素矩形选取最适配的最大尺寸,请筛选 MSN_SIZES_BY_POSITION[position],保留满足 s[0] <= width && s[1] <= height 的尺寸,然后选择面积最大的条目,并格式化为 'position:WxH'。

插屏广告和奖励广告

共用的生命周期:loadAdsAsync(isRewarded) → showAdsAsync(instanceId) → showAdsCompletedAsync。插屏广告传入 false,奖励广告传入 true。对于奖励广告,showAdsCompletedAsync 调用成功即表示已获得奖励。广告打开期间,请暂停游戏和音频。

function showInterstitial({ onOpened, onClosed, onFailed } = {}) {
    return msstart
        .loadAdsAsync(false)
        .then((adInstance) => msstart.showAdsAsync(adInstance.instanceId))
        .then((adInstance) => {
            if (onOpened) onOpened();
            return adInstance.showAdsCompletedAsync;
        })
        .then(() => {
            if (onClosed) onClosed();
        })
        .catch((error) => {
            if (onFailed) onFailed(error);
        });
}

function showRewarded({ onOpened, onRewarded, onClosed, onFailed } = {}) {
    return msstart
        .loadAdsAsync(true)
        .then((adInstance) => msstart.showAdsAsync(adInstance.instanceId))
        .then((adInstance) => {
            if (onOpened) onOpened();
            return adInstance.showAdsCompletedAsync;
        })
        .then(() => {
            if (onRewarded) onRewarded();
            if (onClosed) onClosed();
        })
        .catch((error) => {
            if (onFailed) onFailed(error);
        });
}

状态机取值——插屏广告:'loading' | 'opened' | 'closed' | 'failed';奖励广告还包含 'rewarded'。

社交——分享

function share(shareOptions) {
    // shareOptions: typically { title, text, url } — see msstart docs
    return msstart.shareAsync(shareOptions);
}

排行榜

仅支持写入——submitGameResultsAsync(score)。排行榜由宿主界面呈现;客户端没有读取排行榜的端点。

function leaderboardsSetScore(score) {
    return msstart.submitGameResultsAsync(score);
}

应用内购买

四种操作都位于 $msstart.iap 下。在商品目录中将本地商品 ID 映射为 msstart 商品 ID。IAP 要求 userAccountType === 'personal'——调用前请检查 isPaymentsSupported。

重要:失败响应会以内联方式作为 { code: 'IAP_*_FAILURE', description } 返回并成功解析,而不是被拒绝——请务必检查 code,再判断响应是否成功。

获取商品目录
function paymentsGetCatalog() {
    if (!options.payments) return Promise.reject();

    return msstart.iap.getAllAddOnsAsync({ productId: options.gameId }).then((msnProducts) => {
        if (msnProducts.code === 'IAP_GET_ALL_ADD_ONS_FAILURE') {
            throw new Error(msnProducts.description);
        }
        return options.payments.map((product) => {
            const platformId = product.msn?.id ?? product.id;
            const p = msnProducts.find((m) => m.productId === platformId);
            return {
                id: product.id,
                title: p.title,
                description: p.description,
                publisherName: p.publisherName,
                inAppOfferToken: p.inAppOfferToken,
                isConsumable: p.isConsumable,
                price: `${p.price.listPrice} ${p.price.currencyCode}`,
                priceCurrencyCode: p.price.currencyCode,
                priceValue: p.price.listPrice
            };
        });
    });
}
购买

宿主返回 { receipt, receiptSignature },或 { code: 'IAP_PURCHASE_FAILURE', description }。

const purchases = [];

async function paymentsPurchase(id) {
    const product = options.payments.find((p) => p.id === id);
    if (!product) return Promise.reject();
    if (!isPlayerAuthorized) await authorizePlayer();

    const platformProductId = product.msn?.id ?? product.id;
    const purchase = await msstart.iap.purchaseAsync({ productId: platformProductId });

    if (purchase.code === 'IAP_PURCHASE_FAILURE') throw new Error(purchase.description);

    const merged = { id, ...purchase.receipt, receiptSignature: purchase.receiptSignature };
    purchases.push(merged);
    return merged;
}
消耗购买

通过 msstart productId(来自缓存的购买记录)进行消耗。返回 { consumptionReceipt, consumptionSignature },或 { code: 'IAP_CONSUME_FAILURE', description }。

async function paymentsConsumePurchase(id) {
    const idx = purchases.findIndex((p) => p.id === id);
    if (idx < 0) return Promise.reject();
    if (!isPlayerAuthorized) await authorizePlayer();

    const response = await msstart.iap.consumeAsync({ productId: purchases[idx].productId });
    if (response.code === 'IAP_CONSUME_FAILURE') throw new Error(response.description);

    purchases.splice(idx, 1);
    const result = {
        id,
        ...response.consumptionReceipt,
        consumptionSignature: response.consumptionSignature
    };
    delete result.productId;
    return result;
}
获取购买记录

IAP_GET_ALL_PURCHASES_FAILURE 应视为空列表。

async function paymentsGetPurchases() {
    if (!isPlayerAuthorized) await authorizePlayer();

    try {
        const response = await msstart.iap.getAllPurchasesAsync({ productId: options.gameId });
        purchases.length = 0;
        response.receipts.forEach((purchase) => {
            const product = options.payments.find(
                (p) => (p.msn?.id ?? p.id) === purchase.productId
            );
            purchases.push({
                id: product ? product.id : purchase.productId,
                ...purchase,
                receiptSignature: response.receiptSignature
            });
        });
        return purchases.slice();
    } catch (error) {
        if (error?.code === 'IAP_GET_ALL_PURCHASES_FAILURE') return [];
        throw error;
    }
}

评论

支持 Markdown