十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用 Vega 编写可玩的吃豆人游戏:信号状态机、事件流与数据触发器的实战解析

用 Vega 编写可玩的吃豆人游戏:信号状态机、事件流与数据触发器的实战解析 数据可视化【免费下载链接】vegaA visualization grammar.项目地址https://gitcode.com/gh_mirrors/ve/vega点击查看免费下载导读这篇指南带你从零拆解一个反直觉的 Vega 作品——由 mathiastiberghien约 1320 行中发现游戏里的全部逻辑——键盘移动、吃豆加分、幽灵 AI 追捕、能量豆反杀、计分与最高分、碰撞重开——没有一行命令式 JavaScript全部由 Vega 的 Signals信号、Event Streams事件流、Data Triggers数据触发器以及 group mark 的嵌套编码组合而成。读完本文你将掌握如何在 Vega 中把键盘事件流映射为信号驱动的位置更新、如何用timer事件流驱动游戏主循环、如何用indata/setdata表达式读写数据集做碰撞检测与决策排序、如何用触发器实现吃豆/吃鬼的增删数据最终能在自己的交互可视化中复刻这套信号状态机 数据触发器架构。说明pacman.md页面本身是一张示例页front-matter 中spec: pacman指向同名.vg.json文件其实体内容是配套的完整 Vega 规格文件。本文以该规格文件为主干结合仓库内 docs/docs/signals.md、docs/docs/event-streams.md、docs/docs/triggers.md、docs/docs/data.md、docs/docs/expressions.md 官方文档以及 packages/vega-functions/src/functions/data.js 源码进行印证与扩充。示例总览一个纯声明式的街机游戏在进入细节前先看这张来自示例图库的真实运行截图它包含了本游戏的全部核心元素黑色背景上的蓝色迷宫、黄色吃豆人、红蓝绿橙四只幽灵、白色小豆子与能量豆、以及score/high score两块计分文字。该示例由以下 Vega 顶层构件组成完整定义见 docs/examples/pacman.vg.json构件数量/内容在本游戏中的角色signals约 60 个游戏的全部状态网格尺寸、玩家坐标、四只幽灵坐标/方向/决策、能量、分数、重开标志等data10 个数据集静态关卡数据gums、powerGums、walls、ghosts与运行时状态eatenGums、eatenGhosts、四个*Decisions决策表scales2 个 band 比例尺把逻辑网格坐标0–14映射到 600×600 画布像素marks9 个 mark 定义背景矩形、迷宫墙、计分文字、豆子、吃豆人 group、幽灵 group游戏主循环没有使用setInterval或requestAnimationFrame而是完全依赖 Vega 内建的timer事件流。规格中大量出现两种时间节奏{type: timer, throttle: 500}或简写选择器timer:500500ms 一拍驱动玩家与幽灵的格子位移、吃豆人嘴巴开合动画、superPower能量倒计时{type: timer, throttle: 400}timer:400400ms 一拍驱动幽灵在被堵住时的重试换向计数。关于throttle的语义docs/docs/event-streams.md 明确说明The minimum time in milliseconds between captured events (default 0). New events that arrive within the throttling window will be ignored. For timer events, this property determines the interval between timer ticks.也就是说对timer而言throttle就是两次 tick 的间隔。数据模型用静态数据表描述迷宫关卡Vega 的数据模型是表格式的见 docs/docs/data.md每个数据集是一组记录record记录是带命名属性的普通 JS 对象。本游戏完全绕过了外部数据加载全部用values内联定义关卡符合 Data Properties 中At most one of the source, url, or values的约束。网格与坐标系统grid信号定义了逻辑迷宫大小同时被两个 band 比例尺与sequence变换引用{name: grid, value: {width: 15, height: 15}}columns/rows数据集用sequence变换生成 0..14 的整数序列作为 band 比例尺scaleX/scaleY的 domain比例尺的 range 则由rangeWidthDelta/rangeHeightDelta信号动态计算把 15×15 的逻辑网格居中铺满 600×600 画布{ name: rangeWidthDelta, update: (width - blockSize*grid.width)/2 }, { name: blockSize, init: min(width/grid.width, height/grid.height) }, { name: scaleX, type: band, domain: {data: columns, field: data}, range: [{signal: rangeWidthDelta}, {signal: width-rangeWidthDelta}], padding: 0 }blockSize是 600/15 40px 的格子边长blockSize/2在所有 mark 的offset里反复出现用于把符号symbol或弧arc居中到格子中心。豆子、能量豆与墙体gums普通豆子用values列出约 200 个{x, y}坐标powerGums只在四个角落各放一颗{x:0,y:0}、{x:14,y:0}、{x:0,y:14}、{x:14,y:14}。两者都用一个formula变换预生成查找键{ name: gums, values: [ {x:0, y:1}, {x:0, y:2}, ... ], transform: [ {type: formula, expr: datum.x-datum.y, as: key} ] }walls数据集是迷宫的全部墙体每个{x, y, vertical}记录表示一段墙key 为x-y-verticalvertical: true表示竖墙、false表示横墙。墙体渲染成rectmark宽度/高度按方向取blockSize/10细线或blockSize整格并做边界裁剪{ type: rect, from: {data: walls}, encode: { enter: { x: {signal: datum.x grid.width ? datum.x : grid.width-1, scale: scaleX, offset: {signal: datum.vertical? (datum.x grid.width ? datum.x 0 ? 0 : -1 : blockSize - 2):0}}, y: {signal: datum.y grid.height ? datum.y : grid.height-1, scale: scaleY, offset: {signal: datum.vertical? 0: datum.y grid.height? datum.y 0 ? 0 : -1 : blockSize -2}}, fill: {value: blue}, width: {signal: datum.vertical?blockSize/10:blockSize}, height: {signal: datum.vertical?blockSize:blockSize/10} } } }幽灵初始位置ghosts数据集只定义四个初始点与颜色之后每一只幽灵的活坐标都演进为独立的信号gRedX/gRedY、gBlueX/gBlueY、gGreenX/gGreenY、gOrangeX/gOrangeY{ name: ghosts, values: [ {x: 0, y: 0, color: red}, {x: 14, y: 0, color: steelblue}, {x: 0, y: 14, color: green}, {x: 14, y: 14, color: orange} ] }关键表达式indata与setdata要读懂这套查询式碰撞检测必须先理解两个表达式函数。它们定义在 packages/vega-functions/src/functions/data.jsexport function indata(name, field, value) { const index this.context.data[name][index: field], entry index ? index.value.get(value) : undefined; return entry ? entry.count : entry; } export function setdata(name, tuples) { const df this.context.dataflow, data this.context.data[name], input data.input; df.pulse(input, df.changeset().remove(truthy).insert(tuples)); return 1; }indata(name, field, value)判断数据集name中是否存在字段field等于value的记录返回命中次数。本游戏中indata(walls, key, x-y-true)用于查询某格是否有一面竖墙indata(eatenGums, key, x-y)用于查询该豆子是否已被吃掉。setdata(name, tuples)把数据集name的内容整体替换为tuples先remove(truthy)清空再insert返回 1。本游戏用setdata(gRedDecisions, [...])把四方向候选列表灌进决策数据集。indata之所以高效是因为它通过index: field预建的哈希索引直接查值index.value.get(value)。这与 docs/docs/expressions.md 对indata的官方说明一致Tests if the data set with a given name contains a datum with a field value that matches the input value同节还说明data(name)返回指定数据集的数组、未找到时返回空数组。信号状态机从键盘事件到游戏主循环Vega 官方文档 docs/docs/signals.md 把信号定义为dynamic variables that parameterize a visualization and can drive interactive behaviors并强调Signal values are reactive: they can update in response to input event streams, external API calls, or changes to upstream signals。本游戏把这句话用到了极致约 60 个信号构成一张有向依赖图任何一个信号变化都会沿依赖链自动传播触发视图重绘。这正是状态机的声明式实现方式。键盘输入流window:keydown游戏的第一步是把方向键事件翻译成偏移量。key信号监听整个窗口的keydown取出浏览器事件的code字段ArrowLeft/ArrowRight/ArrowUp/ArrowDown{ name: key, on: [ {events: window:keydown, update: event.code} ] }xOffset/yOffset是两个纯函数式派生信号把按键码映射为网格位移左/上为 -1右/下为 1{name: xOffset, update: key ArrowRight ? 1 : key ArrowLeft ? -1 : 0}, {name: yOffset, update: key ArrowUp ? -1 : key ArrowDown ? 1 : 0}注意这里用的是update有上游依赖、可响应式重算而不是on。docs/docs/signals.md 的属性表区分了两者update是An update expression for the value of the signal. This expression may include other signals, in which case the signal will automatically update in response to upstream signal changes而on是事件处理器数组。规格里大量信号只用update做派生只有真正对外部事件响应的信号key、pacManX/pacManY、各幽灵坐标、restart、score等才用on。此外信号的init与update互斥本游戏用init设置初始值如pacManX的init: 7用on注册事件驱动更新。玩家位移与墙/鬼碰撞pacManX/pacManY在timer:500上推进且被两个信号把关canMoveX/canMoveY要求前方无墙且无鬼。位移时还实现了经典吃豆人的穿越边界——从一侧出去从另一侧回来{ name: pacManX, init: 7, on: [ {events: timer:500, update: !restart canMoveX ? ((xOffset 0 pacManX 0) ? grid.width - 1 : ((xOffset 0 pacManX grid.width -1) ? 0 : (pacManX xOffset))) : pacManX}, {events: {signal: restart}, update: 7, force: true} ] }canMoveX与hasWallX、hasGhost的定义展示了用indata查墙、用坐标相等判碰撞的范式{ name: hasWallX, update: (xOffset0 indata(walls, key, pacManX - pacManY -true)) || (xOffset0 indata(walls, key, (pacManX 1) - pacManY -true)) ? true : false }, { name: hasGhost, update: !superPower (((pacManX xOffset gRedX) (pacManY yOffset gRedY)) || ... ) }值得注意的细节hasGhost只在!superPower时成立——吃下能量豆后玩家可以直接穿过幽灵去吃掉它们这是无敌帧的声明式表达。pacManX的第二个 handler 监听restart信号并force: true强制复位到起点 (7,7)force属性在 docs/docs/signals.md 中的定义是A boolean flag (default false) indicating whether or not updates that do not change the signal value should propagate——这是确保即使值相同也要通知下游的关键开关。吃豆人动画与能量豆状态嘴巴开合是一个 500ms 的timer驱动布尔翻转{ name: pacManIsOpen, init: true, on: [ {events: {type: timer, throttle: 500}, update: !pacManIsOpen} ] }superPower是吃能量豆后的剩余步数它同时响应两件事每 500ms 自减 1能量随时间耗尽一旦testPowerEaten命中就重置为 30{ name: superPower, init: 0, on: [ {events: {signal: pacManX || pacManY}, force: true, update: superPower 0 ? superPower - 1 : 0}, {events: {signal: testPowerEaten}, update: testPowerEaten ? 30 : superPower} ] }这里的事件源{signal: pacManX || pacManY}是信号引用式事件流见 docs/docs/event-streams.md 的 Signal References 一节pacManX与pacManY任一变化即触发倒计时。superPower的数值会影响玩家移动时是否允许穿过幽灵hasGhost、幽灵是否进入害怕模式逃跑 AI、变白、半透明、以及吃豆人弧形的填充色fill: {signal: superPower ? red: yellow}吃豆人本体是一个group markname: pacMan通过update编码把位置绑定到信号、把 group 尺寸绑定到 band 比例尺内部再嵌套arc身体startAngle/endAngle按开口状态与水平朝向计算和symbol眼睛{ type: group, name: pacMan, encode: { update: { x: {signal: pacManX, scale: scaleX}, y: {signal: pacManY, scale: scaleY}, width: {scale: scaleX, band: true}, height: {scale: scaleY, band: true} } }, marks: [ { type: arc, encode: { enter: { outerRadius: {signal: (blockSize/2)*0.8}, stroke: {value: black}, x: {signal: blockSize/2}, y: {signal: blockSize/2} }, update: { fill: {signal: superPower ? red: yellow}, endAngle: {signal: (pacManIsOpen? 5*PI/2-PI/6:5*PI/2-0.001)*(xOffset 0 ? 1: xOffset)}, startAngle: {signal: (pacManIsOpen? PI/2PI/6:PI/2)*(xOffset 0 ? 1: xOffset)} } } } ] }分数与最高分计分完全由信号响应式驱动。三个事件源分别对应吃普通豆10、吃能量豆50、吃幽灵100碰撞即通过testEaten/testPowerEaten/testEatenGhost三个信号暴露{ name: testEaten, update: !restart (indata(gums, key, pacManX-pacManY) !indata(eatenGums, key, pacManX-pacManY)) ? {x:pacManX, y:pacManY, key:pacManX-pacManY} : null }, { name: score, init: 0, on: [ {events: {signal: testEaten}, update: testEaten ? score 10 : score}, {events: {signal: testPowerEaten}, update: testPowerEaten ? score 50 : score}, {events: {signal: testEatenGhost}, update: testEatenGhost ? score 100 : score}, {events: {signal: restart}, force: true, update: restart ? 0 : score} ] }, { name: hiScore, init: 0, on: [ {events: {signal: score}, update: max(score,hiScore)} ] }testEaten的语义值得展开indata(gums, ...)判断该格是豆子!indata(eatenGums, ...)判断还没被吃过——两个查询合起来构成该格有未吃的豆子这一谓词命中时返回一个{x, y, key}对象null表示未命中。score信号用testEaten ? score 10 : score做条件自增hiScore用max(score, hiScore)保持最高分两个文本 mark 在update编码里分别以{signal: score}和{signal: hiScore}显示hiScore面板的opacity用{signal: hiScore?1:0}控制有分才显示。数据触发器吃豆、吃鬼与重开的增删逻辑如果说信号负责算那么**数据触发器Trigger**负责存。Vega 的 docs/docs/triggers.md 定义Triggers enable dynamic updates to data sets or mark items when specific conditions are met. When a trigger expression – typically referencing one or more signals – evaluates to a truthy value, one or more data updates (insert, remove, toggle and/or modify) are applied.官方文档还特别提醒triggers are not supported for derived data sets——本游戏里用到on触发器的数据集eatenGums、eatenGhosts都是带values的基表恰好符合该约束。eatenGums豆子的吃掉状态eatenGums在三种触发器下增删数据{ name: eatenGums, on: [ {trigger: testEaten, insert: testEaten}, {trigger: testPowerEaten, insert: testPowerEaten}, {trigger: restart, remove: restart} ] }testEaten命中时把{x, y, key}插入eatenGums于是豆子 mark 的update.opacity立即用同一把key查询并置 0 隐藏update: { opacity: [{test: indata(eatenGums, key, datum.x-datum.y), value: 0}, {value: 1}] }restart为真时remove全部remove: restart中布尔真值表示移除所有数据见 docs/docs/triggers.md 的 remove 说明实现重开后豆子全部恢复。eatenGhosts被吃的幽灵状态幽灵被吃后进入eatenGhosts数据集superPower结束!superPower为真时清空{ name: eatenGhosts, on: [ {trigger: testEatenGhost, insert: testEatenGhost}, {trigger: !superPower, remove: !superPower} ] }testEatenGhost只在superPower期间判断玩家与四只幽灵是否重叠命中时返回幽灵数据对象含color。被吃幽灵的视觉反馈很直观它的坐标信号在indata(eatenGhosts, color, red)为真时钉在复活点红鬼回 (6,7)蓝/绿/橙鬼回 (7,7)/(8,7)而它的 symbol mark 用shape动态切换——indata(eatenGhosts, color, parent.color)命中时画眼睛图案未命中时画完整幽灵轮廓update: { shape: {signal: indata(eatenGhosts, color, parent.color) ? M16.459004,... : M13.952596,...}, fill: {signal: superPower? white : parent.color}, opacity: {signal: superPower?0.7:1} }这里用到表达式里的parent上下文parent.color引用的是 group 的数据对象即ghosts数据集里的当前记录而superPower时fill变白、opacity降到 0.7正是害怕/可吃状态的视觉化。restart碰撞检测与重开restart信号在timer:500上检测玩家与任意一只未复活幽灵同格并配合!superPower能量期撞鬼不算死{ name: restart, on: [ {events: timer:500, update: !superPower ((gRedX pacManX gRedY pacManY) || (gBlueX pacManX gBlueY pacManY) || (gGreenX pacManX gGreenY pacManY) || (gOrangeX pacManX gOrangeY pacManY))} ] }restart一旦为真会沿着信号依赖链级联复位pacManX/pacManY回 (7,7)、四只幽灵回出生点、score清零同时hiScore通过max保留、eatenGums清空。这些复位 handler 全部带force: true保证即使复位后的值和当前值相同也会向下游传播。幽灵 AI决策表、排序与重试的声明式实现四只幽灵的 AI 是同一套模板按颜色复制gRed/gBlue/gGreen/gOrange四组同名后缀信号核心思路是贪心追玩家 能量期逃跑全部用信号推导完成。第一步生成四方向候选表gRedDelta计算幽灵到玩家的向量gRedPreferences则用setdata一次性把[{d:up, i:...}, {d:down, i:...}, {d:left, i:...}, {d:right, i:...}]灌入gRedDecisions数据集i是该方向的优先级分数1 最优先4 最差{ name: gRedDelta, update: {dx: pacManX-gRedX, dy: pacManY-gRedY} }, { name: gRedPreferences, update: setdata(gRedDecisions,[{d:up, i: (gRedLastDir down ? 4 : (abs(gRedDelta.dy) abs(gRedDelta.dx) ? (gRedDelta.dy0 ? (superPower ? 4 : 1) : (superPower ? 1 : 4)) : (gRedDelta.dy0 ? (superPower ? 3 : 2) : (superPower ? 2 : 3))))}, ...]) }这个优先级表达式的规则可以归纳为普通模式superPower为假优先朝与玩家水平/垂直距离更大的轴方向移动贪心逼近禁止原地掉头gRedLastDir down时up的优先级为 4能量模式superPower为真同样的比较逻辑但优先级取反——从追变成逃gRedLastDir缓存上一拍的方向用于实现不回头。gRedDecisions数据集本身带collect变换按i升序排序于是data(gRedDecisions)[0]就是当前最优方向{ name: gRedDecisions, values: [], transform: [ {type: collect, sort: {field: i}} ] }第二步墙体阻挡检测与方向确认gRedCanUp/Down/Left/Right四个信号分别查询walls数据集当前格/相邻格是否有对应方向的墙{ name: gRedCanLeft, update: indata(walls, key, gRedX - gRedY -true) ? false : true }, { name: gRedCanUp, update: indata(walls, key, gRedX - gRedY -false) ? false : true }, { name: gRedCanRight, update: indata(walls, key, (gRedX 1) - gRedY -true) ? false : true }, { name: gRedCanDown, update: indata(walls, key, gRedX - (gRedY1) -false) ? false : true }gRedBlocked检查提议方向是否被墙挡住gRedProposedDirection取决策表首个条目gRedDirection只在未受阻时才采纳新方向否则维持原方向{ name: gRedBlocked, on: [ {events: {signal: gRedProposedDirection}, force: true, update: !gRedProposedDirection || (gRedProposedDirection up !gRedCanUp) || (gRedProposedDirection down !gRedCanDown) || (gRedProposedDirection left !gRedCanLeft) || (gRedProposedDirection right !gRedCanRight) ? true : false} ] }, { name: gRedDirection, update: gRedProposedDirection !gRedBlocked ? gRedProposedDirection : gRedDirection }第三步被堵时的 400ms 重试机制由于决策表的i优先级排序在当前方向被堵时可能仍指向死路游戏用gRedTry实现逐条尝试候选方向每 400ms 若仍被堵就把gRedTry加 1上限 3超过归 0 重新轮询gRedDecision取决策表的第gRedTry项{ name: gRedTry, on: [ {events: {type: timer, throttle: 400}, update: gRedBlocked gRedTry 3 ? gRedTry1 : 0} ] }, { name: gRedDecision, update: data(gRedDecisions)[gRedTry] }, { name: gRedProposedDirection, on: [{events: {signal: gRedDecision}, force: true, update: gRedDecision ? gRedDecision.d : none}] }第四步位移、穿越与幽灵间的避让gRedOffsetX/gRedOffsetY把方向翻译成位移gRedX/gRedY在timer:500上推进规则包括被吃indata(eatenGhosts, color, red)时钉在复活点restart时回出生点否则在无墙、无玩家能量期可穿越玩家、无其他幽灵挡路时移动并同样支持左右/上下穿越边界{ name: gRedX, init: gRed.x, on: [ {events: timer:500, update: indata(eatenGhosts, color, red) ? 6 : (!restart !gRedHasWallX !gRedHasPacMan ? ((gRedOffsetX 0 gRedX 0) ? grid.width - 1 : ((gRedOffsetX 0 gRedX grid.width -1) ? 0 : (gRedX gRedOffsetX))) : gRedX)}, {events: {signal: restart}, update: gRed.x, force: true} ] }蓝鬼额外有gBlueHasGhost、绿鬼有gGreenHasGhost检查是否与其他幽灵同格橙鬼的gOrangeHasGhost检查它与前三种颜色的任意一种重叠——这些幽灵间避让条件让四只鬼不会完全叠在一起。幽灵的渲染同样是 group markfrom: {data: ghosts}遍历四只鬼update.x/y按datum.color分发到各自的gRedX/gBlueX/gGreenX/gOrangeX信号组内 symbol 再按parent.color决定形状、填充与透明度见上文 eatenGhosts 部分。数据源验证data()与setdata的运行时行为gRedDecision里用到的data(gRedDecisions)与setdata(gRedDecisions, ...)的运行时行为可以在 packages/vega-functions/src/functions/data.js 中看到完整实现data(name)从this.context.data[name].values.value读取当前表setdata通过df.pulse(input, df.changeset().remove(truthy).insert(tuples))向该数据集的输入节点打一个清空重灌的脉冲触发collect变换重新排序。这意味着决策表不是静态数据而是被信号表达式当作可写缓冲反复刷新——这是该示例最巧妙的用法之一数据集被setdata写入又被data()读回形成一条信号 → 数据集 → 信号的环形数据通道。如何运行与实验这个示例在线体验示例页docs/examples/pacman.md通过 docs/_includes/example 与 docs/_includes/embed 两个 Jekyll include 嵌入渲染embed用new vega.View(vega.parse(spec))实例化视图并挂到页面 DOM 上还提供View Source / Export PNG / Export SVG链接。直接用方向键即可游玩。本地跑起来仓库根目录自带构建产物浏览器里直接引用 docs/vega.js未压缩版或 docs/vega.min.js即可vega.parse(spec)vega.View渲染 docs/examples/pacman.vg.json。规格文件第 2 行声明了$schema: https://vega.github.io/schema/vega/v6.json属于 Vega v6 语法。配套文档Vega 的完整交互编程模型见 docs/docs/signals.md信号与事件处理器、docs/docs/event-streams.md事件流选择器与timer、docs/docs/triggers.md数据触发器、docs/docs/data.md数据集属性与内联values、docs/docs/expressions.mdindata/setdata/data等表达式函数。仓库内其他交互式示例还可参考 docs/examples/bar-line-toggle.vg.json信号驱动的图表切换与 docs/examples/crossfilter-flights.vg.json信号 数据触发器的联动过滤。从 Pac-Man 提炼的 Vega 交互设计模式信号即状态机把游戏状态建模为约 60 个信号用update表达派生关系、用on表达事件响应、用force: true强制传播所有逻辑都在表达式中完成渲染自动跟随。事件流驱动主循环timer事件流 throttle就是 tick本游戏用 500ms 驱动角色移动/动画/能量倒计时400ms 驱动幽灵重试——不同子系统可以有不同节奏。indata做查询式碰撞检测把墙、豆子、被吃状态建成带key的数据表用indata(name, key, x-y)一行表达式完成命中测试比手写坐标数组更符合 Vega 的数据模型。触发器做持久化状态eatenGums/eatenGhosts用insert/remove记录已发生的交互再回灌到opacity、shape等编码实现视觉与逻辑的解耦。setdatadata()构成可写缓冲决策表被反复清空重灌 排序供幽灵 AI 逐条重试——这是数据集在纯数据之外作为运行时状态容器的典型案例。这套信号计算 数据触发器 group 嵌套编码的架构完全来自本仓库可查证的定义与源码可作为你在 Vega 中实现任何回合制/准实时交互逻辑游戏、模拟器、状态机型仪表盘的参考模板。赞分享数据可视化【免费下载链接】vegaA visualization grammar.项目地址https://gitcode.com/gh_mirrors/ve/vega点击查看免费下载相关推荐Vega 交互式图例实战用 Signal 事件流与数据触发器构建可刷选的散点图Vega 交互式图例实战用 Signal 事件流与数据触发器构建可刷选的散点图 导读 本文基于 Vega 官方示例 Interactive Legend 交数据可视化Hasura 事件触发器 × AWS Lambda 实战Node.js 8 编写回写数据库的 Mutation WebhookHasura 事件触发器 × AWS Lambda 实战Node.js 8 编写回写数据库的 Mutation Webhook 本文以仓库中 aws lamb后端API网关数据库GraphQL基于HTML5的吃豆人游戏项目推荐基于HTML5的吃豆人游戏项目推荐 1. 项目基础介绍和主要编程语言 项目名称 : Pacman 项目地址 : https://github.com/mumuy游戏开发前端创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表