视频扑克






4.94/5 (18投票s)
经典街机风格视频扑克
引言
我一直很喜欢扑克游戏的简单玩法,从过去的手持 LCD 游戏机到现在的赌场街机。由于在 CodeProject 上没有找到易于访问的视频扑克实现,我想与我的技术朋友们分享我最喜欢的经典版本的实现。
使用方法
首先,请下载源代码文件并解压。在提取的 Video Poker 目录中,您将找到三个代码文件;VideoPoker.js、VideoPoker.css 和 VideoPoker.html,以及两个包含音频和图像文件的资源目录。如果您有丰富的编程经验,可以跳过代码修改。代码相对简短、易读且注释丰富,有助于理解。只需在文本编辑器中打开代码文件进行阅读或修改,或者在任何浏览器中打开 VideoPoker.html 即可运行。
架构
和许多人一样,我在不同的硬件平台上,在大小屏幕上玩游戏。为了适应这种分辨率和设备的多样性,我选择了原生的 JavaScript、HTML 和 CSS。
为了在不同分辨率下进行渲染,HTML 和 CSS 提供了有力的支持。许多游戏使用一个设置为窗口大小的单一绘图表面,这样做可能需要费力的代码来维护不同分辨率下绘制对象的定位和布局状态。相反,这个游戏将绘图区域限制在发牌区域,然后利用 HTML 和 CSS 内置的布局能力来处理剩余的界面。因此,这种实现可以在宽度仅为 480 像素的窗口中运行,也可以在更宽的窗口(例如 1200 像素)中运行,同时保持更轻量级的代码。
为了在能力不同的设备上实现同等性能,屏幕更新是事件驱动的,而不是连续渲染的。连续渲染要求游戏在无限循环中执行,快速在计算状态和渲染帧之间切换(通常以每秒帧数衡量)。相反,这个游戏仅响应交互进行更新。这种事件驱动的设计减轻了硬件的负担,因为 UI 只在用户响应时进行计算和绘制。
因此,通过使用 HTML 进行结构化并采用事件驱动的 UI 更新,我们可以获得性能良好、响应迅速的代码,并舒适地将其分为 VideoPoker.html、VideoPoker.js 和 VideoPoker.css。顺带一提,这种模式在游戏应用之外也效果很好。如果我们不包含较重的音频和图像文件,我们可以在一个页面中将此游戏作为交互式广告(例如,博彩联盟营销)运行,或者作为启动/加载屏幕,在更复杂的应用程序加载时吸引用户。
代码
打开 JavaScript 文件 VideoPoker.js 时,我们首先会看到一个自描述的枚举 GameStates
,列出了游戏的各种状态。
var GameStates = { // Game state enumeration
Uninitialized: 0,
FirstDeal: 1,
SecondDeal: 2,
HandLost: 3,
HandWon: 4,
GameOver: 5
}
应用程序以 GameStates.Uninitialized
状态开始,一个覆盖游戏窗口的最顶层的 <div>
元素。这个最顶层的加载屏幕会隐藏界面,直到资源被初始化。每个加载的资源(图像或音频)都会触发一个加载完成的处理程序,该处理程序会递减加载资源的总计数。最后一个加载的资源会移除 <div>
以允许游戏进行。
继续向下浏览 JavaScript,我们会发现一组全局常量和属性,用于定义游戏玩法,其中一些如下所示:
var _GameState = GameStates.Uninitialized; // Initial game state
var _StartCredits = 100; // Number of starting credits
var _Credits = _StartCredits; // Number of current credits
var _CurrentBet = 1; // Amount of bet
...
在此之下,我们有三个对象构成了我们的主要数据结构:Deck
、Hand
和 Card
。Deck
对象代表一副标准的五十二张扑克牌,并包含预期的函数,例如 Shuffle
和 Deal
。
var Deck = { // Deck Object - A 52 card poker deck
Cards: null, // Array of Card objects representing one deck
Shuffled: null, // Array of Card objects representing a shuffled deck
SpriteSheet: null, // Image object of uncut card deck
SpriteWidth: 230, // pixel width of card in source image
SpriteHeight: 300, // pixel height of card in source image
Initialize: function () {...},
Shuffle: function () {...},
Deal: function (numCards) {...}
...
Deck
还包含对卡牌图像的引用,这些图像被打包成一个名为“*精灵表*”的单一图像资源。我们使用精灵表是因为一个 Image
对象比五十二个独立的 Image
对象需要更少的处理程序代码。为了可视化,我们的卡牌精灵看起来像这样:
从 Deck
中发出的牌会进入 Hand
对象,该对象保存玩家的五张活动 Card
对象。
function Hand(cards) { // Hand object - The player's active Card objects
this.Cards = cards; // Array of Card objects
this.Evaluate = function () {...} // Return ID of winning hand type, or -1 if losing hand
this.IsRoyal = function () {...}
this.IsFullHouse = function () {...}
this.IsFourOfAKind = function () {...}
this.IsFlush = function () {...}
this.IsStraight = function () {...}
this.IsThreeOfAKind = function () {...}
this.IsTwoPair = function () {...}
this.IsJacksOrBetter = function () {...}
...
Hand
对象还提供实例例程来检查获胜组合(例如,同花、满堂彩、顺子)。需要注意的是,检查获胜牌型的整体过程并未优化。例如,IsFullHouse 和 IsStraight 例程都需要排序的牌作为其评估的一部分。它们不是传递排序后的 Card
数组,而是分别对牌进行冗余排序。这种低效率是故意的,以便每个例程在逻辑上是独立的,希望使其对初学者更易于修改。此外,在解释扑克规则时可能存在争议。例如,如果一副牌包含四张 A,IsTwoPair
函数是否应返回 true?技术上来说,是的,所以我这样编码了。其他人可能会选择不同。获胜牌型的优先级使得这在实际代码执行中无关紧要,但我还是想指出这种主观性的存在。
继续往下,Card
对象纯粹是结构化的,看起来很像这样:
function Card(id, suit, rank, x, y, width, height) { // Represents a standard playing card.
this.ID = id; // Card ID: 1-52
this.Suit = suit; // Card Suit: 1-4 {Club, Diamond, Heart, Spade}
this.Rank = rank; // Card Rank: 1-13 {Ace, Two, ..King}
this.X = x; // Horizontal coordinate position of card image on sprite sheet
this.Y = y; // Vertical coordinate position of card image on sprite sheet
this.Width = width; // Pixel width of card sprite
this.Height = height; // Pixel height of card sprite
this.Locked = false; // true if Card is Locked/Held
this.FlipState = 0; // The flip state of card: 0 or 1 (Back Showing or Face Showing)
}
现在,敏锐的程序员可能已经注意到,在面向对象的环境中,Deck
、Hand
和 Card
对象将是它们各自的类文件。在一个像这样的小应用程序中,我选择将所有 JavaScript 合并到一个文件中,以方便理解架构。您可以在后续开发中将其拆分。
继续,我们会发现一些处理程序,例如 _DealClick
和 _Bet
,它们在玩家交互时执行。我不会在这篇文章中完全描述这些例程,因为代码应该提供足够的上下文,但我们可以看一个。_Bet
例程响应“下注”或“减注”操作,通过调整玩家的信用点、播放相关的声音效果,然后更新 UI。这些步骤希望在例程本身中很清楚,即:
function _Bet(action) {
if (_GameState !== GameStates.FirstDeal &&
_GameState !== GameStates.HandWon &&
_GameState !== GameStates.HandLost)
return; // Only allow bet before being dealt
if (action === '-') { // Bet down requested
if (_CurrentBet > 1) { // Govern minimum bet
_CurrentBet -= 1; // Decrement bet
GameAudio.Play('BetDown');
}
}
else if (action === '+') { // Bet up requested
if (_CurrentBet < 5 && _CurrentBet < _Credits) { // Govern maximum bet
_CurrentBet += 1; // Increment bet
GameAudio.Play('BetUp');
}
}
_UpdateBetLabel();
_UpdateCreditsLabel();
}
代码中还包含与绘制元素相关的函数。构成 Hand
的玩家的五张 Card
对象是界面的一个绘制部分,由 HTML 的 Canvas
对象处理。通过从我们的 Canvas
实例获取图形上下文,我们可以使用它进行渲染。这是我们最外层的绘图例程:
function _DrawScreen() { // Render UI update
if (_GameState == GameStates.Uninitialized) // Redrawn only if loading screen is down
return;
var g = _Canvas.getContext('2d'); // Graphics context
g.clearRect(0, 0, _Canvas.width, _Canvas.height); // Wipe frame clean
for (var i = 0; i < _Hand.Cards.length; i++) { // for each Card in Hand
if (_Hand.Cards[i].FlipState === 1)
_DrawCardFace(g, i); // FlipState == 1
else
_DrawCardBack(g, i); // FlipState == 0
if (_GameState === GameStates.SecondDeal && _Hand.Cards[i].Locked) // Second deal
_DrawCardHold(g, i); // Card is locked by player
}
_UpdateBetLabel(); // Refresh html bet elements
_UpdateCreditsLabel(); // Refresh html credits elements
if (_GameState == GameStates.HandLost || _GameState == GameStates.HandWon)
_DrawHandOverMessage(g);
}
在检查我们是否处于允许绘图的状态后,我们调用 getContext
来获取我们的图形上下文 g
。使用 g
,我们首先通过调用其本机函数 clearRect
并提供要清除的几何边界来清除绘图表面。然后,我们根据 Card
的 FlipState
调用我们自己的绘图例程 _DrawCardFace
或 _DrawCardBack
。最后,我们绘制辅助效果并更新 HTML 元素。如果我们查看 _DrawCardBack
例程,我们可能会理解一些实际的渲染:
function _DrawCardBack(g, cardIndex) {
g.save(); // Push styling context
g.fillStyle = '#300'; // Set dark red card back
var cardX = _HandX + (cardIndex * (_CardWidth + 4) + 4); // Card x position (4px buffer)
g.fillRect(cardX, 0, _CardWidth, _CardHeight); // Render card back
g.restore(); // Pop styling context
}
我们调用 save
将当前的样式上下文推入内存,并调用 restore
将其弹出。在这种情况下,我们只设置了 fillStyle
属性,所以我们可以只重置该特定属性,而无需 save
和 restore
整个样式上下文,但我发现始终在设置样式属性之前调用 save
和 restore
可以提供标准化,从而防止错误并提高可读性。
除了手动绘图,我们还利用了一个内置函数进行 UI 渲染。Window
的一个特殊线程会在每个时间段过去时执行一段特定的代码。我们可以使用此间隔函数来处理奖品字幕的闪烁效果,该效果在每次获胜时都会发生。每次执行函数时,我们都会交替“开启”和“关闭”,切换奖品行的 CSS。
function _PrizeWinBlink() // Handles marquee blink on winning prize row
{
_BlinkOn = !_BlinkOn; // Toggle the effect
var rowStyle = document.getElementById('row' + _WinID).style; // Winning prize
// row's style property
rowStyle.color = _BlinkOn ? '#fff' : '#fc5'; // white to yellow
rowStyle.textShadow = _BlinkOn ? '0 0 1px #fff' : '0 0 10px #a70'; // Toggle white to
// yellow shadow
}
我们通过调用 setInterval
并提供一个处理程序和执行之间的间隔时间来设置此间隔函数(即 setInterval(_PrizeWinBlink, 400)
,它会在每四百毫秒后执行上述代码)。虽然当前 Window
的终止理论上也会终止任何正在运行的间隔函数,但我注意到这并不总是如此。尽管 Window
对象得到了广泛支持,但它并未完全标准化。在多个浏览器窗口的情况下,我可以重现孤立的间隔函数。我们可以通过保留每个间隔函数的引用,然后在 Window
正常卸载时强制关闭来缓解这种情况。
window.onbeforeunload = function () {
if (_PrizeWinThread != null) // If marquee blinking effect is running
// when app is closing
clearInterval(_PrizeWinThread); // Terminate
};
跳转到 JavaScript 的底部,这是我想介绍的最后一部分代码,即 GameAudio
对象,它负责处理游戏的各种 Audio
对象。
Audio
对象是每个供应商实现的本机 JavaScript 对象。这意味着音频支持参差不齐。为了确保我们在不同配置下都能听到音频效果,我们需要同一种文件的多个编码。对于游戏中的每种音效,都会创建三个版本;OGG、MP3 和 WAV。为了帮助选择我们需要哪个版本,每个供应商都需要回答这个问题:您能否播放此媒体类型(通常称为 MIME 类型)?有趣的是,Firefox(和其他浏览器)在指定的媒体类型似乎可以播放时返回*可能*,在不播放就无法确定媒体类型是否可播放时返回*也许*,或者在指定的媒体类型绝对无法播放时返回空字符串(https://mdn.org.cn/en-US/docs/Web/API/HTMLMediaElement/canPlayType)。我觉得空字符串的响应有点奇怪,我在跨浏览器实验中发现空字符串是合理的指示。通过按以下顺序选择媒体类型,我们可以在考虑质量/大小比率的同时获得良好的兼容性。
var audio = new Audio();
var oggCapable = audio.canPlayType('audio/ogg') !== ''; // Check for OGG Media type
var mp3Capable = audio.canPlayType('audio/mpeg') !== ''; // Check for MPEG Media type
var wavCapable = audio.canPlayType('audio/wav') !== ''; // Check for WAV Media type
var extension = oggCapable ? 'ogg' : mp3Capable ? 'mp3' : wavCapable ? 'wav' : '';
...
因为一个 Audio
对象封装了一个声音,所以我们需要多个同一种声音的 Audio
对象来实现特定效果的重叠、异步播放。例如,用户可能在一秒钟内点击两次“Bet Up
”按钮。如果一个持续时间为一秒的 Audio
对象播放,那么玩家在第二次点击时将听不到第二个声音。我们必须为每种效果使用多个 Audio
对象,允许我们缓冲声音并实现重叠播放。我们只需检索效果的缓冲区,增加其缓冲区索引(或在末尾时将其设置为开头),然后播放它。
var buffer = this._SoundEffects[soundName]; // Get the buffer per sound effect
var bufferIndex = this._SoundEffects[soundName + "I"]; // Get buffer's current index
bufferIndex = bufferIndex === buffer.length - 1 ?
0 : bufferIndex + 1; // Increment or reset if at end
this._SoundEffects[soundName + "I"] = bufferIndex; // Set buffer index
buffer[bufferIndex].play(); // Play sound effect at buffer index
...
大致就是这些了。混合一些数据结构、事件处理程序、绘图例程,视频扑克就诞生了。在本文中,我省略了 JavaScript 的某些部分以及大部分 HTML/CSS,假设代码比代码解释更容易阅读。如果我错了,您有紧迫的问题,我可能会在下面的评论中回答。否则,感谢您的关注,祝您编码愉快!
历史
- 2017 年 5 月 20 日 - 首次发布
- 2017 年 5 月 21 日 - 修复了文章中损坏的图片链接
- 2017 年 5 月 24 日 - 根据下方评论中 DSAlCoda 的建议,更新了 JavaScript 文件和文章,以准确反映扑克惯例