与 Vue 一起扬帆出海——我对大班的看法
小时候,我花了很多时间在我的 Apple 2 上。我玩过很多不同的游戏,但我最喜欢的游戏之一是Taipan!。
[
](https://res.cloudinary.com/practicaldev/image/fetch/s--CMRQey0t--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://static.raymondcamden.com/images/2019 /08/TaipanGameTitle.png)
按来源,合理使用,https://en.wikipedia.org/w/index.php?curidu003d8888638
Taipan 是一个位于远东的基本贸易模拟器。你有一艘有存储能力的船,可以在多个港口买卖货物。游戏有基本的战斗,放债人,以及其他让事情变得有趣的细节,但对我来说,我的乐趣来自纯粹的磨砺。我会在晚上玩几个小时,看看我能赚多少钱。 (当然,一旦我发现了放债人的漏洞,致富就变得微不足道了。)
作为我今年“获得更多 Vue 应用程序体验”基本目标的一部分,我决定(尽我所能)使用 Vue.js 来重建游戏。不过,我不想要精确的重建,在我的版本中我做了一些更改。
-
首先,我摆脱了战斗。我_讨厌_游戏的战斗方面,因为它感觉非常缓慢。我喜欢它为游戏增加了风险的事实,但不喜欢它如何扼杀节奏。在我的版本中,您可能会受到海盗的攻击,但他们只会造成破坏并窃取一些商品。
-
我摆脱了放债人。这是一个有趣的方面,但在到达港口时也减慢了游戏的节奏。
-
我通过李渊摆脱了“动摇”的一面。我也喜欢这个方面,最终可能会把它带回来。
-
我摆脱了仓库。对我来说,这总是让人分心。
-
我也跳过了使我的一件商品非法的行为。
差不多就是这样,但还有其他一些较小的模块。与原始游戏相比,我的游戏感觉更加活泼和快速,这让我很喜欢玩它。
我还尝试尽可能多地使用键盘。你可以在这里阅读我在该领域的工作:Working with the Keyboard in your Vue App。我没有让 everything 键盘可以访问,但是从一个端口到另一个端口的导航可以完全通过键盘完成,并且在演奏时感觉它是一个非常好的设置。所以在我进入代码之前,如果你想尝试一下,你可以在这里玩:
https://taipan.raymondcamden.now.sh/
您可以在此处查看源代码:
https://github.com/cfjedimaster/vue-demos/tree/master/taipan/
好的,让我们看一下代码。我不会详细介绍每一行,而是在高层次上谈论(对我而言)更有趣的部分。
Taipan 同时使用了 Vue Router 和 Vuex。我的路由器使用没什么特别的。有一条回家路线向您介绍游戏。只询问您的姓名的“设置”路线。然后游戏路线就完成了大部分工作。接下来是处理从一个港口到另一个港口的“旅行”路线。最后有一条游戏结束路线,显示您的最终统计数据。
我对 Vuex 的使用很有趣。与我的Lemonade Stand游戏一样,我花了很多时间思考应该在我的观点中加入什么与应该进入商店。我绝对认为我有一些不应该存在的观点。我认为 Vue 开发的这个特殊方面会随着应用程序的迭代而改变。
让我们看看游戏是如何发生的。每个回合由以下逻辑组成。
-
首先,我要求 Vuex 考虑随机事件。这确实是整个游戏中最困难的方面。核心“转向、买入、卖出”等逻辑并不太难。但是处理“特殊事件”肯定是有问题的。
-
我的视图提示输入。这可以是以下之一 - 购买商品、出售商品、修复损坏、升级船舶或移动到另一个港口。
“提示输入”方面与键盘有关。我的解决方案涉及根据您正在做的当前“状态”显示菜单。所以最初的状态是 - 显示菜单。但是如果你想买东西,我会切换到另一个菜单,提示你要数量和好。您可以在 Game.vue 的布局中看到这一点。
<template>
<div>
<p>
The date is {{ date }}, Captain {{captain}}. You are currently docked at {{ port }}.
</p>
<div class="container">
<Stats />
<Hold />
<Prices />
</div>
<p v-if="canUpgrade">
<strong>Good News!</strong> You can upgrade your ship for {{ upgradeCost }}.
<span v-if="money < upgradeCost">Unfortunately you do not have the funds.</span>
<span v-else><button @click="doUpgrade">Purchase Upgrade</button></span>
</p>
<p v-if="!keyState">
<b>Menu:</b> Type <code>B</code> to buy, <code>S</code> to sell,
<span v-if="damage"><code>R</code> to repair, </span>
<code>M</code> to go to another port or <code>Q</code> to quit.
</p>
<p v-if="keyState == 'Move'">
Move to
<span v-for="(p, i) in ports" :key="i">{{ i+1 }}) {{ p }} </span>
<br/>
Or <code>C</code> to cancel.
</p>
<p v-if="keyState == 'Buy'">
Buy
<input v-model.number="toBuyQty" type="number" min="0"> units of
<select v-model="toBuy">
<option v-for="(s, i) in prices" :value="s" :key="i">{{ s.name }}</option>
</select>
for {{ purchasePrice | num }}.
<button :disabled="cantBuy" @click="buyGoods">Purchase</button>
<br/>
Or <code>C</code> to cancel.
</p>
<p v-if="keyState == 'Sell'">
Sell
<input v-model.number="toSellQty" type="number" min="0"> units of
<select v-model="toSell">
<option v-for="(s, i) in prices" :value="s" :key="i">{{ s.name }}</option>
</select>
for {{ sellPrice | num }}.
<button :disabled="cantSell" @click="sellGoods">Sell</button>
<br/>
Or <code>C</code> to cancel.
</p>
<p v-if="keyState == 'Repair'">
Spend
<input v-model.number="toRepairQty" type="number" min="0"> on repairs.
<button :disabled="cantRepair" @click="doRepair">Repair</button>
<br/>
Or <code>C</code> to cancel.
</p>
</div>
</template>
进入全屏模式 退出全屏模式
我将我的很多显示内容移到了组件中,这使该页面的布局主要集中在响应您的输入上。keyState值是我处理动态更改当前菜单的方式。这是 JavaScript:
import Hold from '@/components/Hold.vue'
import Prices from '@/components/Prices.vue'
import Stats from '@/components/Stats.vue'
export default {
data() {
return {
keyState:null,
ray:null,
toBuy:null,
toBuyQty:0,
toSell:null,
toSellQty:0,
toRepairQty:0
}
},
components:{
Hold, Prices, Stats
},
created() {
this.$store.commit('newTurn');
window.addEventListener('keypress', this.doCommand);
},
destroyed() {
window.removeEventListener('keypress', this.doCommand);
},
computed: {
cantBuy() {
return (
this.toBuy === null
||
(this.toBuy.price * this.toBuyQty) > this.money
||
this.toBuyQty + this.shipUsedSpace > this.holdSize
)
},
cantRepair() {
return this.toRepairQty > this.money;
},
cantSell() {
if(this.toSell === null) return true;
let avail = 0;
for(let i=0;i<this.hold.length;i++) {
if(this.hold[i].name === this.toSell.name) {
avail = this.hold[i].quantity;
}
}
console.log('avail is '+avail);
return (
this.toSellQty > avail
)
},
canUpgrade() {
return this.$store.state.offerUpgrade;
},
captain() {
return this.$store.state.name;
},
damage() {
return this.$store.state.damage;
},
date() {
return this.$store.getters.gameDate;
},
hold() {
return this.$store.state.hold;
},
holdSize() {
return this.$store.state.holdSize;
},
money() {
return this.$store.state.money;
},
port() {
return this.$store.state.port.name;
},
ports() {
return this.$store.getters.ports;
},
prices() {
return this.$store.state.prices;
},
purchasePrice() {
if(!this.toBuy) return 0;
/* disabled due to warning about unexpected side effect, which makes sense
if(this.toBuyQty < 0) this.toBuyQty = 0;
*/
return this.toBuy.price * this.toBuyQty;
},
repairCost() {
return this.$store.getters.repairCost;
},
sellPrice() {
if(!this.toSell) return 0;
return this.toSell.price * this.toSellQty;
},
shipUsedSpace() {
return this.$store.getters.shipUsedSpace
},
upgradeCost() {
return this.$store.getters.upgradeCost;
}
},
methods: {
buyGoods() {
//in theory not needed due to other checks
if(!this.toBuy) return;
if(this.toBuyQty <= 0) return;
this.$store.commit('purchase', { good: this.toBuy, qty: this.toBuyQty });
this.keyState = null;
},
doUpgrade() {
this.$store.commit('upgrade', { cost: this.upgradeCost });
},
sellGoods() {
if(!this.toSell) return;
if(this.toSellQty <= 0) return;
this.$store.commit('sale', { good: this.toSell, qty: this.toSellQty });
this.keyState = null;
},
doCommand(e) {
let cmd = String.fromCharCode(e.keyCode).toLowerCase();
/*
How we respond depends on our state. If keyState is null,
it meand we aren't doing anything, so BSM are valid.
*/
if(!this.keyState) {
if(cmd === 'b') {
console.log('Buy');
this.toBuy = null;
this.toBuyQty = 0;
this.keyState = 'Buy';
}
if(cmd === 's') {
console.log('Sell');
this.toSell = null;
this.toSellQty = 0;
this.keyState = 'Sell';
}
if(cmd === 'm') {
console.log('Move');
this.keyState = 'Move';
}
if(cmd === 'r') {
console.log('Repair');
this.keyState = 'Repair';
}
if(cmd === 'q') {
this.$router.replace('/end');
}
return;
}
//keystate for move
if(this.keyState === 'Move') {
if(cmd === 'c') {
this.keyState = null;
return;
}
cmd = parseInt(cmd, 10);
for(let i=0;i<this.ports.length;i++) {
if(cmd-1 === i) {
console.log('going to move to '+this.ports[i]);
this.$router.replace({ name:'travel',
params: {
destination: this.ports[i],
destinationIndex: i
} });
}
}
}
//keystate for buy
if(this.keyState === 'Buy' || this.keyState === 'Sell') {
if(cmd === 'c') {
this.keyState = null;
return;
}
}
},
doRepair() {
// in theory not needed
if(this.toRepairQty >= this.money) return;
if(this.toRepairQty >= this.repairCost) this.toRepairQty = this.repairCost;
this.$store.commit('repair', { total: this.toRepairQty, repairCost: this.repairCost });
this.keyState = null;
}
}
}
进入全屏模式 退出全屏模式
这是相当多的,我很抱歉。可能最有趣的方面是doCommand,我在其中响应键盘事件并根据当前状态处理输入。我觉得这可以做得更好,但对于初稿,我很满意。
我不满意的部分是computed中的所有项目,它们只是简单地接触到 Vuex 状态及其吸气剂。我知道我可以使用mapState让它更干净一些,但我决定暂时搁置。 (我将强迫自己在下一个演示中使用它。)
除此之外,虽然这里的大部分代码只是处理输入并与商店交互。这是我很棒的设计的快速屏幕截图。
[
](https://res.cloudinary.com/practicaldev/image/fetch/s--ij-_6IzO--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://static. raymondcamden.com/images/2019/08/taipan1.png)
让我们看一下Travel.vue。这是您在端口之间移动时看到的临时屏幕。
<template>
<div>
<h1>On the sea...</h1>
<p>
You are on the way to {{ destination }}.
</p>
<p v-if="randomEvent">
{{ randomMessage }}
</p>
<p v-if="damage >= 100">
<strong>Your ship is completely destroyed!</strong>
</p>
</div>
</template>
<script>
export default {
computed: {
damage() {
return this.$store.state.damage;
},
destination() {
return this.$route.params.destination;
},
randomEvent() {
return this.randomMessage !== '';
},
randomMessage() {
return this.$store.state.randomMessage;
}
},
created() {
// check for random event
this.$store.commit('generateRandomEvent', {destination: this.$route.params.destination});
// this feels icky
let destinationIndex = this.$route.params.destinationIndex;
if(this.$store.state.newPortIndex) {
destinationIndex = this.$store.state.newPortIndex;
}
let timeToWait = 1000;
// if there was a special event, we need more time to read, and possibly end the game
if(this.randomEvent) {
timeToWait += 2000;
}
setTimeout(() => {
console.log('done waiting');
if(this.damage >= 100) {
this.$router.replace('/end');
} else {
this.$store.commit('setPort', destinationIndex);
this.$router.replace('/game');
}
}, timeToWait);
}
}
</script>
进入全屏模式 退出全屏模式
最有趣的方面是created中的setTimeout。这个想法是你进入这个视图,然后自动移出。通常这会在一秒钟内完成,但如果发生随机事件,我会将其延迟到总共三秒钟,以便您有时间阅读发生的事情。而且由于随机事件实际上可以为您结束游戏,因此我有一些逻辑可以移动到结束视图。
最后,我们来看看这家店。我将把它分解一下,而不是仅仅粘贴整个东西。
/*
starting year for the game
*/
const BASE_YEAR = 1900;
const MONTHS = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
/*
Ports. For now ports just have names but I may add boosts later, like port
X for good Y is good.
*/
const PORTS = [
{
name:'Bespin'
},
{
name:'Dagobah'
},
{
name:'Naboo'
},
{
name:'Coruscant'
},
{
name:'New Boston'
}
];
/*
Goods have a value range representing, generally, what they will sell for.
illegal=true means there is a chance it will be stolen
*/
const GOODS = [
{
name:'General',
salesRange: [5, 20],
illegal:false
},
{
name:'Arms',
salesRange: [60, 120],
illegal:false
},
{
name:'Silk',
salesRange: [200, 500],
illegal:false
},
{
name:'Spice',
salesRange: [3000, 6000],
illegal:true
}
];
//how much each upgrade adds
const HOLD_UPGRADE = 10;
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min; //The maximum is exclusive and the minimum is inclusive
}
进入全屏模式 退出全屏模式
我的商店从影响游戏玩法的各种常量开始。你可以看到港口(是的,我从旧游戏中转移了一点)、货物等等。港口现在只是名称,但我的意图是允许港口“偏爱”某些商品。商品支持价格下跌的范围,您可以看到我将Spice标记为非法,但尚未实施。最后我得到了一个随机效用函数,getRandomInt。
这是我的商店状态:
state: {
name:'',
port:null,
money:100000,
turn:0,
holdSize:100,
hold:[],
prices: [],
damage:0,
randomMessage:'',
newPortIndex:null
},
进入全屏模式 退出全屏模式
其中大部分是不言自明的,但请注意最后两项,randomMessage和newPortIndex,仅用于旅行时发生的特殊事件。
现在让我们看看各种突变。首先是bootstrap,它只是为新游戏设置东西。
bootstrap(state) {
state.port = PORTS[0];
GOODS.forEach(g => {
state.hold.push({name:g.name, quantity: 0});
});
},
进入全屏模式 退出全屏模式
接下来是我的特殊事件处理:
/*
A random event is one of the following:
Nothing (ie nothing happened, no event
Storm sends you to X port
Storm damages you Y percentage points
Pirates attack - steal items + Y damage
Also note we skip random events for the first ten turns or so
*/
generateRandomEvent(state, info) {
state.randomMessage = '';
state.offerUpgrade = false;
if(state.turn < 10) return;
let rand = getRandomInt(0, 100);
//nothing
if(rand < 60) return;
if(rand >= 60 && rand < 70) {
console.log('storm redirection');
let newPort = null;
while(!newPort || newPort.name === info.destination.name) {
state.newPortIndex = getRandomInt(0, PORTS.length);
newPort = PORTS[state.newPortIndex];
}
state.randomMessage = 'A storm has blown you off course to ' + newPort.name;
console.log(state.randomMessage);
}
if(rand >= 70 && rand < 80) {
let damage = getRandomInt(1, 12);
console.log('Storm damages you for '+damage);
state.randomMessage = 'A violent storm damages your ship!';
state.damage += damage;
}
if(rand >= 80 && rand < 90) {
//note, if your hold is empty, we ignore everything;
//now get the hold and filter to items with stuff
let heldItems = state.hold.filter(h => {
return h.quantity > 0;
});
if(heldItems.length === 0) return;
console.log('pirates attack and damage and steal shit');
//first, do damange, bit less than storm to be nice
let damage = getRandomInt(1, 7);
console.log('Storm damages you for ' + damage);
console.log('state.hold with items',JSON.stringify(heldItems));
//select the index to steal
let stealIndex = getRandomInt(0, heldItems.length);
console.log('going to steal from '+JSON.stringify(heldItems[stealIndex]));
let stealAmt = getRandomInt(1, heldItems[stealIndex].quantity + 1);
console.log('stealing '+stealAmt);
let target = -1;
for(let i=0;i<state.hold.length;i++) {
if(heldItems[stealIndex].name === state.hold[i].name) target = i;
}
state.randomMessage = 'Pirates attack your ship and steal some cargo!';
state.damage += damage;
state.hold[target].quantity -= stealAmt;
}
if(rand >= 90) {
state.offerUpgrade = true;
}
},
进入全屏模式 退出全屏模式
如您所见,我基本上只是选择一个随机数,然后根据结果,可能会发生一些不同的事情。其中一个(offerUpgrade)实际上会在您到达港口时触发,而不是“在运输途中”。
其余的突变很有趣,因为主要应用商品更改并进行维修或升级。在getters部分,我认为这些部分很整洁。
gameDate(state) {
let years = Math.floor((state.turn-1)/12);
let month = (state.turn-1) % 12;
return `${MONTHS[month]} ${BASE_YEAR + years}`;
},
进入全屏模式 退出全屏模式
gameDategetter 是我处理显示逐月和逐年推进的日期的方式。
rank(state) {
// your final score is just based on money, cuz life
if(state.money < 10000) return 'Deck Hand';
if(state.money < 50000) return 'Ensign';
if (state.money < 100000) return 'Lieutenant';
if (state.money < 1000000) return 'Commander';
//below is 10 million, just fyi ;)
if (state.money < 10000000) return 'Captain';
//below is 100 million, just fyi ;)
if (state.money < 100000000) return 'Admiral';
return 'Grand Admiral';
},
进入全屏模式 退出全屏模式
rankgetter 只是根据您赚到的钱返回一个标签。请注意,我在那里使用了注释来帮助我阅读大量数字。有一个数字分隔符的 ES 提案旨在使这更容易。例如,假设最后一个条件是:
if (state.money < 100_000_000) return 'Admiral';
进入全屏模式 退出全屏模式
不幸的是,这还没有得到很好的支持。最新的 Chrome 有它,但没有 Firefox。
最后一个有趣的地方是处理船舶升级的成本:
upgradeCost(state) {
// the cost to upgrade is based on the size of your ship;
let cost = state.holdSize * 200 * (1 + getRandomInt(5,10)/10);
return Math.floor(cost);
}
进入全屏模式 退出全屏模式
我的目标是让它变得昂贵,并且随着你变得越来越大,它会逐渐变得昂贵。当人们玩游戏并提供反馈时,我会对此进行调整。
无论如何,我希望这个演示对人们来说很有趣,并且一如既往,我非常愿意接受对我的设计决策的反馈和批评!请在下面给我留言,让我知道您的想法!
标题照片由Joshua J. Cotten在 Unsplash 上拍摄
更多推荐

所有评论(0)