Speed Tap 是一款适用于 Amazon Alexa 的游戏,它使用 Echo Buttons 来测试玩家的反应时间和注意力。该按钮会在随机颜色之间循环,玩家必须在按钮为绿色时点击该按钮。随着游戏的进行,灯光速度加快,难度也越来越大。
该游戏结合了许多Alexa API和AWS 服务来创造引人入胜的体验,同时保持简单的游戏玩法。这个故事将重点解释为什么需要这些以及它们的大致工作原理,以便其他开发人员了解什么是可能的以及如何进行。
这项技能是使用 Node、AWS 服务和alexa-app框架从头开始构建的。
从较高的层面来看,这些是使这款游戏无论是作为 Alexa Skill 还是从开发/编程的角度来看都独一无二的东西:
拉姆达
游戏的所有代码都在 AWS Lambda 中。它是使用 alexa-app 框架在 Node.js 中编写的。Alexa 事件(玩游戏的用户)和 CloudWatch Events 使用相同的 lambda 函数代码,这会触发后台任务来更新排行榜。
动态数据库
个人球员和全球世界纪录统计数据都存储在 DynamoDB 中。对 DDB 的访问是使用 Amazon 的 aws-sdk 包完成的,我在其顶部编写了一个自定义层以简化 API 并在现有的 Promise 接口上提供一个异步/等待接口。
每次交互时,用户数据和状态都会保存到 DDB,从而使游戏能够以随着时间的推移和适当的方式与用户进行交互(请参阅下面的用户体验部分)。
S3
Lambda 函数每 5 分钟生成一个包含所有高分和当前世界纪录数据的 JSON 文件,并将其存储在公共 S3 存储桶中。然后通过 AJAX 从网站加载此 JSON 文件以显示排行榜并以大文本宣布当前的记录保持者。
后台任务 - CloudWatch Events
需要一个“后台任务”来定期更新包含网站数据的 JSON 文件。由于无法按计划触发 Lambda 函数,因此使用 CloudWatch Events 触发触发 Lambda 函数的事件。
然后,Lambda 函数代码会检测它是由 Alexa 还是 CloudWatch Events 触发的,并做出相应的行为。
许多 Alexa API 和功能被用来创建这个游戏。以下是使用的内容和原因的摘要。
小工具API
Gadgets API需要与 Echo Buttons 交互,实际上有两种风格,它们都必须声明为技能所需的接口。
GAME_ENGINE是用于监听 Echo Buttons 事件的接口。
GADGET_CONTROLLER是用于实际设置按钮本身的灯光模式的接口。
这是两个不同的 API,要让它们无缝地协同工作,一开始理解起来有点棘手。仔细阅读文档、了解每个文档的局限性并在设计您的体验时了解什么是可能的、什么是不可能的,这一点很重要。
客户资料 API
Customer Profile API允许技能请求有关用户的信息,例如姓名、电子邮件地址和电话号码。使用时,该技能会在用户的 Alexa 应用程序中向用户出示一张卡片以授予权限,然后才能访问此信息。如果用户请求,Speed Tap 使用此 API 获取用户名以显示在网站的排行榜上。
技能购买
In-Skill Purchases (ISP) API允许技能请求用户使用真钱进行实际购买,以解锁技能中的内容或功能。Speed Tap 使用“消耗品”ISP (CISP),这是可以购买和用完的物品,而不是一次性购买和订阅,它们不会在使用时消失。
Speed Tap 使用户能够在点击错误的颜色后继续。每个新用户都会获得 5 条额外的生命,当他们离开后,他们可以选择使用 CISP 再购买 10 条生命。
展示
Speed Tap 利用显示接口 API与 Echo Show 和 Echo Spot 设备上的显示功能进行交互,方法是在游戏开始时显示彩色“启动画面”,并在每次按下按钮时显示当前回合。
随着带有显示器的 Echo 设备变得越来越普遍,技能通过视觉提示和附加信息来补充其音频输出非常重要。
声音库
Alexa Skills Kit 声音库是可用于任何技能的音频片段集合。这些音频文件由亚马逊存储,可以插入到任何响应中,而无需技能作者创建、存储或交付它们。例如:
src
='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_countdown_loop_32s_full_01'/>
随着用于多种用途的声音样本的选择越来越多,这个库为技能作者提供了一种简单的方法来为他们的技能添加一点额外的个性和乐趣。Speed Tap 在等待用户按下按钮以增加戏剧性和张力时将这些声音用于音乐,以及用于积极和消极事件。
演讲者
Alexa 语音输出可以比默认的文本转语音更具表现力的方式说出某些词。这些“ Speechcons ”仅限于每种支持语言的一组特定单词,并使用标记插入到您的 SSML 响应中:
<say-as interpret-as="interjection">abracadabra!say-as>
Speed Tap 使用 Speechcons 响应每个按钮按下,让游戏对用户来说更有趣,并增加一点多样性。玩游戏的孩子特别喜欢有趣的输出。
Alexa 技能活动
技能事件API允许技能响应更改或事件,而无需用户直接与技能交互。例如,当用户启用或禁用技能时,可以通知该技能。
当用户使用手机上的 Alexa 应用程序启用或禁用对其客户资料信息的访问时,Speed Tap 使用 Skill Events API 立即做出响应。收到此事件后,Speed Tap 会立即调用 API 获取用户姓名,因此无需用户再次玩游戏即可更新排行榜。
语音应用程序的用户体验至关重要且微妙。与网络和移动等显示媒体不同,音频需要更长的时间来呈现信息,并且用户无法处理选项列表或扫描屏幕以了解他们下一步想做什么。
Speed Tap 实施了许多改进用户体验的方法,这些方法虽然不那么明显,但却产生了很大的不同。
共享的全球经验
与移动应用程序不同,大多数 Alexa 游戏都是单人游戏,不与其他玩家社区互动。用户是孤立的,通常玩游戏是为了个人高分或自己探索游戏。
但是……和别人一起玩游戏更有趣!
Speed Tap 朝着这个方向迈出了独特的一步,让玩家可以看到他们的分数与其他人的比较情况。网站上的排行榜列出了每个玩家的个人最高分以及他们用了多少次连续得分来达到这一目标。添加你的真实姓名的能力让你有吹牛的权利,看看你是否在打败你的朋友。
玩家知道什么与实际上是什么?
为用户提供相关信息的一个重要部分是跟踪他们所知道的,而不是实际情况。
在 Speed Tap 中,这与世界纪录有关。当用户玩游戏时,他们可能会被告知当前的世界纪录是 25 次点击。但下次他们比赛时,世界纪录可能会增加到 30,他们很想知道。
懒人技能只会在用户每次开始游戏时告诉用户世界纪录。但为了获得更好的体验,该技能需要知道用户上次被告知的世界纪录是什么,并且只有在更改时才告诉他们。通过这种方式,他们可以获得他们想要的信息,但如果相同,则不必每次都被迫忍受关于当前世界纪录的相同句子。
Speed Tap 通过保留两个独立的数据区域来跟踪此类事件。一个保存当前的真实信息(例如当前的世界纪录和持有者),而另一个保存相同的信息,但处于用户最后知道的状态。然后将这些进行比较以确定应该告诉用户什么。代码如下所示:
sayif `Your high score is ${user.high_score} `;
// Check to see if there is a new world record to inform the user about
if (game.world_record) {
if (user.world_record && user.world_record < game.world_record) {
say `and there is a new world record. The highest score is now ${game.world_record}`;
}
else if (game.world_record_user && game.world_record_user===request.data.session.userId) {
say `and you still hold the world record`;
}
else {
say `and the world record is ${game.world_record}`;
}
user.world_record = game.world_record;
}
增量体验/会话跟踪
由于用户会多次玩游戏,因此不应每次都向他们展示冗长的音频介绍。当他们多次使用特性时,不应该要求他们忍受对他们已经理解的东西的解释。
出于这个原因,跟踪用户与技能交互时的体验对于技能来说很重要,这样技能就知道何时可以缩短交互。这正是人类所做的,通过这样做,一项技能似乎更自然。
Speed Tap 为每个用户保留一个“体验”对象,该对象随依赖于它的每种交互类型进行更新。然后,根据用户当前的体验水平更改输出。下面是一些示例代码:
// Open with a more different intro depending on how often the user has played
if (request.experience("session_count",false) <= 1) {
say `Welcome to ${speedtap}. This is a game of quick reactions and concentration. Would you like to hear a quick explanation of how to play?`;
}
else {
if (request.experience("session_count",false) < 4) {
say `Welcome back to ${speedtap}.`;
}
else {
response.say(`Welcome back.`);
}
}
experience 对象还用于确定是否应该给出有关排行榜如何工作、所需权限等的解释。
随机输出
一遍又一遍重复相同输出的技能变得非常重复并且感觉不自然。至少随机化部分输出很重要,这样技能就不会感觉那么“僵硬”。
对于 Speed Tap,简单的随机化是使用我在其他技能中实施的文本后处理技术完成的。
输出文本时,{braces} 可以放在作为随机同义词候选词的任何单词或短语周围。然后,创建一个可能的同义词列表,并在输出字符串中仅使用主键。单词会自动随机化,使技能感觉更自然,不那么机械化。
// The synonym list
const outputSynonyms = {
"Okay, ": ["Okay, ","Alright, ",""]
};
// A method in the response object
"randomizeSynonyms": function(synonyms) {
try {
let ssml = this.response.response.outputSpeech.ssml;
ssml = ssml.replace(/\{([^\}]+)\}/g, function (m, m1) {
if (synonyms && synonyms[m1]) {
let s = synonyms[m1];
if (s.length) {
// simple array of synonyms
return s[Math.floor(Math.random() * s.length)];
}
}
return m1;
});
this.response.response.outputSpeech.ssml = ssml;
} catch(e) { }
}
// Example
response.say("{Okay, }let's play."); // Could be: "Alright, let's play" or "let's play"
// Randomize synonyms in the output during app.post()
response.randomizeSynonyms(outputSynonyms);
本节将深入探讨该技能的实现细节,并展示演示每种功能如何实现的代码片段。
触发切换
Lambda 函数需要根据它是从 CloudWatch Events(更新排行榜)还是从 Alexa 触发而表现不同。Lambda 函数处理程序使用以下代码进行切换:
// connect to lambda
exports.handler = function(event, context, callback) {
if (event && "aws.events"===event.source) {
// Scheduled Event!
app.scheduled(event).then((response)=>{
callback(null,response);
}).catch((e)=>{
callback(e);
});
}
else {
// Alexa Request
log("Alexa Request");
app.handler(event, context, callback);
}
};
按钮侦听器事件
技能使用 Gadgets API 监听按钮事件。该技能返回一个指令,定义触发技能的确切条件。这是一个示例按钮侦听器指令,它将导致在按下按钮或超时时调用技能。
{
"type" : "GameEngine.StartInputHandler",
"timeout" : 25000,
"proxies" : ["button"],
"recognizers" : {
"button_down_recognizer" : {
"type" : "match",
"anchor" : "end",
"fuzzy" : false,
"pattern" : [{
"action" : "down"
}
]
}
},
"events" : {
"button_down_listener" : {
"meets" : ["button_down_recognizer"],
"reports" : "matches",
"shouldEndInputHandler" : true
},
"timeout" : {
"meets" : ["timed out"],
"reports" : "history",
"shouldEndInputHandler" : true
}
}
}
光指令
有几种不同格式的 GadgetController.SetLight 指令可以根据按钮的状态更改按钮的颜色。API 文档详细介绍了这些状态是什么以及它们如何工作。
这是一个循环显示颜色并等待用户在按钮变为绿色时按下按钮的示例指令。当代码接收到按钮按下事件时,它还会接收按钮被按下时的颜色。这允许代码检查用户是否在它为绿色时按下它。
{
"type" : "GadgetController.SetLight",
"version" : 1,
"targetGadgets" : [],
"parameters" : {
"triggerEvent" : "none",
"triggerEventTimeMs" : 0,
"animations" : [
{
"repeat" : 255,
"targetLights" : ["1"],
"sequence" : [{
"durationMs" : 1000,
"blend" : false,
"color" : "0000FF"
}, {
"durationMs" : 1000,
"blend" : false,
"color" : "FFA500"
}, {
"durationMs" : 1000,
"blend" : false,
"color" : "FF0000"
}, {
"durationMs" : 1000,
"blend" : false,
"color" : "FF00FF"
}, {
"durationMs" : 1000,
"blend" : false,
"color" : "00FF00"
}
]
}
]
}
}
坚持
所有持久化都是使用 DynamoDB (DDB) 完成的。
每次调用技能时,它都会检查会话是否有用户对象。如果没有,它会尝试从请求中给定的 userId 的 DDB 加载它。如果用户记录存在,它会加载它并将其存储在会话中,这样就不需要在每次调用技能时都检索它。
用户数据在发生变化时会持久保存回 DDB。例如,新的高分、会话计数增量、使用额外的生命等。
在触发 In-Skill Purchase 流程的情况下,实际需要技能退出,会话结束。然后,当玩家完成购买时,将创建一个新会话。出于这个原因,在某些情况下需要保留当前游戏的状态。但在大多数情况下,当前游戏的回合和分数需要从用户记录中清除,而不是持久化。
持久化用户记录的通用函数会清除不应持久化的数据,并调用 DDB 持久化层来存储数据。
async function persist_user(persist_game_state) {
// Persist user session back to db if it has changed
if (user) {
let u = JSON.parse(JSON.stringify(user));
if (!persist_game_state) {
delete u.round;
delete u.lives_used;
delete u.state;
delete u.buttonConnected;
}
delete u.game;
delete u.listenerRequestId;
await ddb.put(app.user_persistence_table, u);
}
}
数据库层
javascript 的 ask-sdk 模块有很多很棒的功能,但它的 API 对我来说仍然太低级了。我围绕 ask-sdk 函数编写了一个包装器,进一步抽象了功能。
包装器所做的其中一件事是提取从 ask-sdk 函数返回的 Promises,而是公开异步函数,以便我的代码可以使用 await。
包装器代码包含在源代码中,我打算将其清理并作为 NPM 模块分发。下面是一个示例函数,它简化了从 DDB 中检索单个记录的过程:
'get': async function(table, keyAttribute, keyValue) {
let params = {TableName:table, Key:{ [keyAttribute]:keyValue } };
return docClient.get(params).promise()
.then( (item)=> {
if (!item || !item.Item) { return null; }
return item.Item;
});
}
会话/体验维护
游戏源中的用户对象包括一个“体验”对象,用于存储用户在玩游戏时的体验。作为此体验对象的一部分,每次启动游戏时都会增加 session_count 属性。
体验对象还包含跟踪用户是否已经听到特定响应的键。如果他们有,那么下次触发时他们会得到一个较短的版本。例如:
if (round===1 && request.experience('intro_1')) {
say `That was easy, but now the lights will get a little faster every round. How far can you go? Keep going.`;
}
else if (round===1) {
say `Nice, Keep going.`;
}
文本响应后处理
构建被翻译成语音的文本输出有一些烦恼,例如复数化和 is/are 等。Speed Tap 包含文本后处理功能,可大大简化常见用例。每次调用技能后,都会自动对输出 SSML 执行此后处理。
以下是后处理功能可以执行的操作的一些示例。
let coins=1;
say `There {are} ${coins} coin{s} left.`;
后处理器处理 {are} 和 {s} 并查找附近的数字以确定应如何处理它们。在本例中,它看到“1”并且输出为:
"There is 1 coin left."
如果 coins==2,那么同一个 say 调用的输出将是:
"There are 2 coins left."
状态和上下文意图
意图缺乏状态和上下文。意思是,有全局 AMAZON.YesIntent 和 AMAZON.NoIntent 意图,当用户说“是”或“否”时会被触发。但是这些意图不知道问的是哪个问题,所以一种常见的方法是在每个处理程序中构建逻辑知道被问到的问题并采取适当的行动。
相反,我在 alexa-app 框架上构建了一个层,允许我创建“上下文意图”。当用户处于某种状态时,是或否将在用户所在的上下文中触发函数,而不是全局意图。
例如,如果询问用户是否要继续,他们的会话会更新以反映他们处于“继续”状态,并且适当地处理是或否:
app.intentMap({
"continue": {
[YES]: async()=>{
await continue_game();
}
,[NO]: async()=>{
await end_game();
}
}
});
技能购买
In-Skill Purchases 允许玩家购买额外的生命以在他们搞砸时继续玩。这实际上是一个复杂的主题,有很多实施怪癖和细节。一些重要的注意事项值得一提:
要触发 ISP 购买流程,必须从技能返回指令并且必须关闭会话。该指令如下所示:
{
'type': 'Connections.SendRequest',
'name': 'Buy',
'payload': {
'InSkillProduct': {
'productId': "XYZ"
}
},
'token': "arbitrary-token"
}
Alexa 技能活动
Alexa 技能事件允许技能在没有用户主动与技能交互时响应事件。对于 Speed Tap,它处理用户授予或撤销使用其真实姓名的权限的情况。
下面的代码是 Speed Tap 如何响应此事件,使用 API 检索用户的真实姓名,更新他们的用户记录,并持久化它。
app.on('AlexaSkillEvent.SkillPermissionAccepted', async()=>{
try {
let user_id = request.data.context.System.user.userId;
user = await ddb.get(app.user_persistence_table, "userid", user_id);
let name = await app.api("/v2/accounts/~current/settings/Profile.name");
user.name = name;
user.linked = true;
await persist_user();
} catch(e) {
console.log(e.message);
}
});
为了响应这些事件,技能必须注册它希望收到这些事件的通知。使用 CLI 时,这些存储在 manifest.events.subscriptions 下的 skill.json 中:
"subscriptions": [
{
"eventName": "SKILL_PERMISSION_ACCEPTED"
},
{
"eventName": "SKILL_PERMISSION_CHANGED"
}
]
声音库
声音库是一种非常好的方法,可以轻松地将声音包含在技能中。这些声音都按类别列在“声音库”页面上。
您无需执行任何特殊操作即可使用这些声音。只需找到您要使用的声音,复制列出的 SSML 内容,然后将其插入到您的响应中。
src
='soundbank://soundlibrary/animals/amzn_sfx_bear_groan_roar_01'/>
我假设使用该soundbank:
协议可以最大限度地减少延迟,因为声音文件存储在靠近 Alexa 内部服务器的某些边缘服务器上。
启动画面/显示
当技能启动时,会显示一个图形启动画面。这是通过使用 Display 接口在响应中返回指令来实现的。可重用函数将功能包装在一个地方:
function display_splash_screen(request,response) {
if (has_display(request)) {
response.directive({
"type" : "Display.RenderTemplate",
"template" : {
"type" : "BodyTemplate1",
"backButton" : "HIDDEN",
"backgroundImage" : {
"contentDescription" : "",
"sources" : [{
"url" : "https://alexaspeedtap.com/splash.jpg",
"size" : "MEDIUM"
},{
"url" : "https://alexaspeedtap.com/splash-square.jpg",
"widthPixels":640,
"heightPixels":640
}
]
}
}
});
}
}
这不一定代表展示的最佳做法,但它可以完成工作。
使用 Display 指令时,技能必须检测用户的设备是否有屏幕,如果没有则不发送指令,否则会抛出错误。has_display() 函数封装了该检查。
const has_display = function(request) { try { return !!request.data.context.System.device.supportedInterfaces.Display; } catch(e) { return false; }};
还必须为该技能注册 RENDER_TEMPLATE 接口。如果跳过此步骤,任何返回 Display 指令的尝试都将导致异常。
写入 S3
当后台任务运行并创建排行榜 JSON 文件时,必须从技能代码中将其写入 S3 存储桶。
我写了一个简单的可重用函数来将任意 javascript 对象写入我的存储桶:
const putObjectToS3 = async function (o,filename) {
let s3 = new AWS.S3({'region': 'us-east-1'});
let params = {
Bucket: "alexa-speed-tap",
Key: filename,
Body: JSON.stringify(o),
ContentType: "application/json",
CacheControl: "no-cache",
ACL: "public-read"
};
return s3.putObject(params).promise();
};
CORS
由于排行榜 JSON 文件存储在 AWS S3 上,在生成的 Amazon URL 上提供服务,浏览器将阻止它从其他域加载,例如 AlexaSpeedTap.com。
为了允许浏览器访问不同服务器上的内容,托管服务器 (S3) 必须明确允许此类请求。
这可以使用如下策略在权限 --> CORS 配置下的 S3 存储桶上进行配置。
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<AllowedOrigin>*AllowedOrigin>
<AllowedMethod>GETAllowedMethod>
<MaxAgeSeconds>3000MaxAgeSeconds>
<AllowedHeader>*AllowedHeader>
CORSRule>
CORSConfiguration>
构建 Echo Buttons 是一个独特的挑战,因为 API 和实际的小工具功能有点棘手。但是,一旦将预期调整为实际可能的情况,并构建了基本功能,完善体验和让游戏变得有趣就相对简单了。
尽管 Speed Tap 是一款易于玩和理解的游戏,但它使用了许多使其真正独一无二的 Alexa 功能和概念,并为玩家提供了不同于其他游戏的体验。
我希望你喜欢它!
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
全部0条评论
快来发表一下你的评论吧 !