使用 React 构建 RPG 风格的物品栏(第一部分)
我们正在构建什么
设置
布局
游戏逻辑
启用编辑器
标记级别完成
调整编辑器
包起来
照片由Rhii Photography 拍摄,来自Unsplash
大约一个月前,我决定要开发一款游戏。我想制作一款需要玩家编写代码,但玩法又像老式角色扮演游戏的游戏。
鉴于这是一项浩大的工程,我决定分阶段进行游戏开发。我一开始着手制作战斗系统,但很快意识到,在深入研究之前,我需要从头开始。
好了,现在我们要构建一个库存系统。在深入代码之前,让我们先来看看这个应用程序的实际功能。
我们正在构建什么
这将是一个分屏编码应用程序,很像Flexbox Froggy,只不过我们不是在移动青蛙,而是在将游戏物品移动到具有持久状态的库存中,并且用户将输入 JavaScript 而不是 CSS。
我们将使用react-ace 包中的Ace Editor作为我们的代码编辑器组件。
我们还将实现一个自定义网格检查器,它将作为物品栏位之间的分隔符。
好了,闲话少说,开始写代码吧!
设置
我们将从我们的朋友开始。create-react-app
npx create-react-app dev-inventory
cd dev-inventory
接下来,我们将进行安装react-ace,然后启动开发服务器:
npm install react-ace
npm start
然后我们可以清理我们的文件App.js,删除几乎所有东西(是的,包括徽标和 CSS 导入):
function App() {
return <div className="App"></div>;
}
export default App;
让我们导入必要的库,让 Ace Editor 组件正常工作:
// allows us to render the <AceEditor> component
import AceEditor from "react-ace";
// enable the user to enter JavaScript in the editor component
import "ace-builds/src-noconflict/mode-javascript";
// choose a theme
import "ace-builds/src-noconflict/theme-dracula";
然后我们可以前往react-ace 代码仓库获取初始代码,并根据我们的使用场景进行一些修改:
function App() {
function onChange(newValue) {
console.log("change", newValue);
}
return (
<div className="App">
<AceEditor
mode="javascript"
theme="dracula"
onChange={onChange}
name="UNIQUE_ID_OF_DIV"
editorProps={{ $blockScrolling: true }}
/>
</div>
);
}
太棒了!现在我们有了一个外观精美的编辑器组件:
如果你打开控制台,你会发现我们实际上并没有执行代码;我们只是按照函数中的指示打印编辑器的内容onChange:
function onChange(newValue) {
console.log("change", newValue);
}
我们稍后再谈。首先,让我们先完成其余的布局设置。
布局
我们希望向用户展示四个不同的部分:
- 编辑
- 控制台(无需打开开发者工具即可查看提示和错误)
- 游戏剧情内容
- 存货
为了简单起见,我们将尽可能少地创建组件。
编辑器和控制台将位于单独的区域,占据屏幕的左半部分。
故事内容和物品栏将显示在另一个区域,占据屏幕的右半部分。
让我们首先修改我们的文件App.js,使其具有以下结构:
return (
<div className="App">
<div className="code-area">
<AceEditor
mode="javascript"
theme="dracula"
onChange={onChange}
name="UNIQUE_ID_OF_DIV"
editorProps={{ $blockScrolling: true }}
/>
<div id="console" className="console"></div>
</div>
<div className="content">
Game content goes here
<div className="inventory"></div>
</div>
</div>
);
以及相应的样式index.css
.App {
display: flex;
height: 100vh;
background-color: #16324f;
color: #3c6e71;
font-weight: bold;
}
.code-area {
width: 50%;
display: flex;
flex-direction: column;
border-right: 3px solid #3c6e71;
}
.console {
border-top: 3px dashed #3c6e71;
background-color: #13293d;
height: 20%;
padding: 0.5rem;
}
.inventory {
margin-bottom: 15vh;
display: grid;
grid-template-columns: repeat(12, 3.5vw);
grid-template-rows: repeat(5, 3.5vw);
grid-gap: 0px;
text-align: center;
background-color: #282a37;
}
.content {
overflow: hidden;
width: 50%;
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
你会注意到布局的左侧看起来有点歪斜:
这是因为我们无法<AceEditor>直接设置组件的样式。我们需要通过 props 来设置样式:
<AceEditor
mode="javascript"
theme="dracula"
onChange={onChange}
width="auto"
height="100%"
name="UNIQUE_ID_OF_DIV"
editorProps={{ $blockScrolling: true }}
/>
现在我们应该得到类似这样的结果:
我们将暂缓向库存添加网格叠加层,因为一旦我们开始将物品移入库存,使用开发者工具进行调试就会更容易。
说到这里,我们开始往内容部分添加一些内容吧。
游戏逻辑
我们需要开始考虑如何处理入库物品。至少,我们需要一种方法来跟踪物品的状态,以及一种方法来识别它们。
最终,我们需要一种方法来处理不同尺寸的物品(药水比匕首占用空间小,匕首又比剑占用空间小,以此类推)。但目前,我们只专注于占用一个物品栏位的物品。
为了构建这个结构,我们将创建一个新文件,src/items.js
const items= {
scroll: {
height: 1,
width: 1,
row: 0,
col: 0,
},
potion: {
height: 1,
width: 1,
row: 0,
col: 0,
},
gem: {
height: 1,
width: 1,
row: 0,
col: 0,
},
amulet: {
height: 1,
width: 1,
row: 0,
col: 0,
},
ring: {
height: 1,
width: 1,
row: 0,
col: 0,
},
};
export default items;
我们可以一次性向用户显示所有五个项目,但最终我们会有足够的项目来填满整个库存,所以我们将采取不同的方法。
我们将采用关卡制。每个关卡都会有一个物品供玩家放入物品栏。物品放入物品栏后,玩家即可进入下一关。
由于每一层都包含一个物品,我们可以将item.js文件重命名为levels.js,然后按如下方式组织文件结构:
const levels = {
1: {
item: {
name: "scroll",
width: 1,
height: 1,
row: 0,
col: 0,
},
done: false,
},
2: {
item: {
name: "potion",
width: 1,
height: 1,
row: 0,
col: 0,
},
done: false,
},
3: {
item: {
name: "gem",
width: 1,
height: 1,
row: 0,
col: 0,
},
done: false,
},
4: {
item: {
name: "amulet",
width: 1,
height: 1,
row: 0,
col: 0,
},
done: false,
},
5: {
item: {
name: "ring",
width: 1,
height: 1,
row: 0,
col: 0,
},
done: false,
},
};
export default levels;
每个关卡都有一个键(关卡编号)、一个项目和一个done布尔值。让我们把这些项目渲染到屏幕上。
首先,我们将导入useState钩子函数以及我们的levels.js模块:
import React, { useState } from "react";
import gameLevels from "./levels"
然后我们将关卡连接到useState钩子函数。我们还会添加一些状态来跟踪当前关卡:
function App() {
const [levels, setLevels] = useState(gameLevels);
const [currentLevel, setCurrentLevel] = useState(1);
// the rest of the App component...
}
现在我们可以创建一个 Level 组件来渲染当前关卡。我们将创建一个新文件,Level.js
import React from "react";
function Level({ currentLevel, levels }) {
return <h1>The current level is {currentLevel}</h1>;
}
export default Level;
现在我们可以将其导入并渲染到我们的App.js文件中:
// other imports
import Level from "./Level";
function App(){
// state, onChange...
return (
<div className="App">
{/* AceEditor, console...*/}
</div>
<div className="content">
<Level currentLevel={currentLevel} levels={levels} />
<div className="inventory"></div>
</div>
)
}
既然我们已经确认组件连接正确,就可以开始渲染关卡的实际内容了。由于我们将所有关卡都发送到了组件<Level>,而我们只需要当前关卡,因此需要编写一些代码来提取匹配的关卡:
function Level({ currentLevel, levels }) {
let activeLevel;
for (const [key, value] of Object.entries(levels)) {
if (key === currentLevel.toString()) {
activeLevel = JSON.stringify(value);
}
}
const { item } = JSON.parse(activeLevel);
return (
<>
<h1>You found: {item.name}!</h1>
<p>
{item.name} position: {item.row}, {item.col}
</p>
</>
);
}
现在我们可以看到第一个展示品的雏形了:
但是……滚动条在哪儿?我们需要在 DOM 中显示一些内容,用户才能真正进入库存页面。我们从flaticon获取一些图片:
| 物品 | 图像 |
|---|---|
| 滚动 | 图片来自 Freepik |
| 药水 | 图片来自 Freepik |
| 宝石 | 图片来自 Freepik |
| 护身符 | 图片来自 Smashicons |
| 戒指 | 图片由尼基塔·戈卢别夫拍摄 |
我们将这些图片保存到public项目文件夹中。然后,我们可以更新文件levels.js,添加图片路径:
1: {
item: {
name: "scroll",
width: 1,
height: 1,
row: 0,
col: 0,
image: "scroll.svg",
},
done: false,
},
2: {
item: {
name: "potion",
width: 1,
height: 1,
row: 0,
col: 0,
image: "potion.svg",
},
done: false,
},
// etc...
现在我们来编辑一下Levels.js,让图片能够显示出来:
return (
<>
<h1>You found: {item.name}!</h1>
<img src={item.image} alt={item.name} />
<p>
{item.name} position: {item.row}, {item.col}
</p>
</>
);
哇……我们的图片好大啊!
我们需要对图片进行一些样式调整,使其尺寸合适。记住,目前我们希望所有物品都只占用一个物品栏位。因此,我们需要确定一个物品栏位的大小标准。
让我们做出这项改变index.css
.scroll,
.potion,
.gem,
.amulet,
.ring {
width: 3.5vw;
height: 3.5vw;
}
并且Level.js
<img
src={item.image}
alt={item.name}
className={item.name}
/>
我们使用3.5vw是因为我们在 中就是这样做的grid-template。所以 一个1按 的1项目翻译成3.5vw,3.5vw一个1按 的2项目翻译成3.5vw,7vw依此类推。
现在我们已经有了关卡的基本布局,我们可以开始编写逻辑,让玩家能够将物品移动到他们的物品栏中。
启用编辑器
到目前为止,我们对这个组件的利用还很有限<AceEditor>。我们提供了一个基本onChange功能,但正如我们所见,它作用不大。我们需要对此进行改进。
这部分有点棘手——不是说如何编写代码,而是如何遵循最佳实践。
为什么?
免责声明: 我并非 JavaScript 专家或安全专家。以下论述仅为推测。如果您对此有任何想法,欢迎在评论区留言!
这里的主要问题是,我们将允许玩家在我们的应用程序中输入 JavaScript 代码,然后由我们的应用程序执行这些代码。换句话说,用户可以在我们的应用程序中输入任何他们想要的 JavaScript 代码。
然而,我们并没有泄露任何敏感信息。我们没有后端服务器,没有密码,没有信用卡信息等等。所以,理论上,恶意用户除了可能通过执行无限循环来锁定自己的浏览器之外,无法造成太大危害。
因此,我们将采用这种new Function()方法。
让我们通过修改函数来设置<AceEditor>组件,使其能够执行玩家的代码onChange:
function onChange(newValue) {
try {
const userInput = new Function(newValue);
try {
userInput();
} catch (e) {}
} catch (e) {}
}
第一个try/catch代码块尝试根据用户输入创建一个函数。内部try/catch代码块尝试运行该函数。这些步骤是必要的,因为我们的onChange函数会在每次按键后运行,这样可以防止玩家在输入过程中应用程序崩溃。
现在,如果我们把以下代码放到编辑器组件中,应该就能看到滚动条移动了:
function moveItem(item, row, col){
const inventory = document.querySelector('.inventory');
item.style.gridColumnStart = col;
item.style.gridRowStart = row;
inventory.insertAdjacentElement('beforeEnd', item);
}
const scroll = document.getElementsByClassName('scroll')[0]
moveItem(scroll,1,1)
这里有几点需要注意:
(0,0)由于我们只更新了 DOM,而没有更新 React 的状态,因此Level 组件中的滚动位置没有改变。- 我们必须使用
[0]语法来获取第一个(也是唯一一个)类名为“scroll”的元素,因为我们还没有设置id。我们不想使用,document.querySelector因为最终会有多个元素具有该类名。.scroll - 由于没有对行和列值进行验证,玩家可能会尝试将滚动条移动到无效的槽位。
- 如果刷新页面,我们需要再次在编辑器中输入该函数。
让我们逐一解决这些问题。
状态
我们很快就会在应用程序中添加很多状态,所以我们暂时先放一放,稍后会一起处理。
添加ID
我们可以id给文件中的每个项目添加一个levels.js:
1: {
item: {
id: 'scroll-1',
name: "scroll",
width: 1,
height: 1,
row: 0,
col: 0,
image: "scroll.svg",
},
done: false,
},
2: {
item: {
id: 'potion-1',
name: "potion",
width: 1,
height: 1,
row: 0,
col: 0,
image: "potion.svg",
},
done: false,
},
// and so on...
然后,我们可以id在Level.js文件中引用它:
<img
id={item.id}
src={item.image}
alt={item.name}
className={item.name}
/>
现在,我们应该能够修改编辑器代码中的以下这行:
//const scroll = document.getElementsByClassName('scroll')[0]
const scroll = document.getElementById('scroll-1');
我们应该仍然能够移动卷轴。
验证
为了验证玩家的输入,我们将创建两个函数,一个用于验证输入,另一个用于在控制台显示错误信息。这两个函数将被放入编辑器组件中:
function log(message){
const consoleDiv = document.getElementById('console');
consoleDiv.innerHTML = `${ message } <br /> <br />` ;
}
function validInput(row, col){
if(!row || ! col) return false;
log('');
const MAX_ROWS = 5;
const MAX_COLS = 12;
let validRow = row <= MAX_ROWS;
let validCol = col <= MAX_COLS;
if(!validRow){
log(`${row} is outside the inventory row range`);
}
if(!validCol){
log(`${col} is outside the inventory column range`);
}
return validRow && validCol;
}
现在我们可以moveItem在编辑器中将该函数修改成如下所示:
function moveItem(item, row, col){
const inventory = document.querySelector('.inventory');
if(validInput(row,col)){
item.style.gridColumnStart = col;
item.style.gridRowStart = row;
item.classList.add(item.id)
inventory.insertAdjacentElement('beforeEnd', item);
}
}
预先填充编辑器
我们不想每次刷新页面时都要把这段代码粘贴到编辑器里,所以让我们把这些函数作为字符串模板放在代码中。
由于这三个函数比较冗长,我们再创建一个文件editor.js来存储默认编辑器值:
const editorValue = `function log(message){
const consoleDiv = document.getElementById('console');
consoleDiv.innerHTML = \`\${ message } <br /> <br />\` ;
}
function validInput(row, col){
if(!row || ! col) return false;
log('');
const MAX_ROWS = 5;
const MAX_COLS = 12;
let validRow = row <= MAX_ROWS;
let validCol = col <= MAX_COLS;
if(!validRow){
log(\`\${row} is outside the inventory row range\`);
}
if(!validCol){
log(\`\${col} is outside the inventory column range\`);
}
return validRow && validCol;
}
function moveItem(item, row, col){
const inventory = document.querySelector('.inventory');
if(validInput(row,col)){
item.style.gridColumnStart = col;
item.style.gridRowStart = row;
item.classList.add(item.id)
inventory.insertAdjacentElement('beforeEnd', item);
}
}
`;
export default editorValue;
请注意,我们需要在所有出现 `a` 的地方使用转义序列,以${variable}防止 JavaScript 对值进行插值,并继续将整个内容视为字符串。
现在我们可以将该值导入到App.js
import editorValue from "./editor";
然后将该值作为属性提供给<AceEditor>
<AceEditor
mode="javascript"
theme="dracula"
onChange={onChange}
width="auto"
height="100%"
name="UNIQUE_ID_OF_DIV"
value={editorValue}
editorProps={{ $blockScrolling: true }}
/>
现在,如果我们刷新页面,所有预先编写好的函数都会出现!
标记级别完成
我们希望玩家在成功将当前关卡的物品移入物品栏后,能够升入下一关。
为此,我们需要能够检测到物品何时被移入库存。我们可以在onChange函数中实现这一点,但是如何从该函数中访问图像呢?
document.getElementById()我们可以使用与当前级别匹配的属性来实现id,但我认为在这里使用 React 的 hook 更有意义useRef。
首先,我们导入它:
import React, { useState, useRef } from "react";
ref然后在我们的App组件中定义一个:
const [levels, setLevels] = useState(gameLevels);
const [currentLevel, setCurrentLevel] = useState(1);
const imageRef = useRef();
接下来,我们将把图像传递ref给我们的<Level>组件,因为图像就存储在那里:
<Level
currentLevel={currentLevel}
levels={levels}
ref={imageRef}
/>
由于我们不能ref直接将 a 作为 prop 传递,因此我们需要React.forwardRef在<Level>组件中使用:
const Level = React.forwardRef(({ currentLevel, levels }, ref) => {
// all of the code up until the return statement is the same
return (
<>
<h1>You found: {item.name}!</h1>
<img
ref={ref}
id={item.id}
src={item.image}
alt={item.name}
className={item.name}
/>
<p>
{item.name} position: {item.row}, {item.col}
</p>
</>
);
});
现在,我们应该能够ref在onChange函数中引用它了:
function onChange(newValue) {
try {
const userInput = new Function(newValue);
try {
const levelItem = imageRef.current;
console.log(levelItem);
userInput();
} catch (e) {}
} catch (e) {}
}
现在,如果我们对编辑器组件进行更改(例如按下Enter),我们应该会在控制台中看到打印出的元素。
接下来,我们需要一些状态来跟踪当前的行和列位置:
const [currentPosition, setCurrentPosition] = useState({ row: 0, col: 0 });
现在我们可以使用levelItem以下方法来判断行和列是否发生了变化0:
const levelItem = imageRef.current;
userInput();
const userRow = levelItem.style.gridRowStart;
const userCol = levelItem.style.gridColumnStart;
if (
userCol &&
userRow &&
(userCol !== currentPosition.col ||
userRow !== currentPosition.row)
) {
//TODO: mark level as complete
setCurrentPosition({ row: userRow, col: userCol });
}
如果我们moveItem()再次运行该函数,然后转到我们的 React 开发工具,我们可以看到状态currentPosition已更新。
物品移动完毕后,我们希望将该关卡标记为已完成,但我们不希望自动将玩家推进到下一关,因为他们可能希望在进入下一关之前改变当前物品的位置。
done这就是我们在每个层级都添加了一个属性的原因levels.js;我们可以创建一个按钮来移动到下一层级,并在当前层级的物品移动到库存后渲染该按钮(这将把“完成”标记为 true):
但这里有个问题:我们activeLevel在组件中计算了变量(我们需要将其标记为“已完成”)<Level>。现在我们需要在组件中访问活动级别App,更合理的做法是在组件中计算活动级别,然后将其值作为 propApp传递给组件:<Level>
// state...
const imageRef = useRef();
let activeLevel;
for (const [key, value] of Object.entries(levels)) {
if (key === currentLevel.toString()) {
activeLevel = value;
}
}
// onChange()...
return (
// change out props for <Level>
<Level activeLevel={activeLevel} ref={imageRef} />
)
并更新Level.js
const Level = React.forwardRef(({ activeLevel }, ref) => {
const { item } = activeLevel;
return (
<>
<h1>You found: {item.name}!</h1>
<img
ref={ref}
id={item.id}
src={item.image}
alt={item.name}
className={item.name}
/>
<p>
{item.name} position: {item.row}, {item.col}
</p>
</>
);
});
App.js现在我们可以在函数中标记关卡已完成onChange:
setCurrentPosition({ row: userRow, col: userCol });
if (!activeLevel.done) {
activeLevel.done = true;
setLevels(levels, ...activeLevel);
}
如果我们查看 React 开发工具,会发现级别 1 的状态已更新done为true。
您可能也注意到,当我们调用并更新状态时,我们在编辑器中输入的新代码消失了setLevels。这是因为我们没有设置任何状态来跟踪<AceEditor>组件。
我们来处理这件事:
function App(){
const[value, setValue] = useState(editorValue);
function onChange(newValue){
// setValue in the inner try/catch
userInput();
setValue(newValue);
}
return (
// replace the value prop
<AceEditor value={value} />
)
}
现在,我们的编辑器状态将在渲染之间保持不变。
好吧,我知道我们很久以前就说过要渲染一个按钮。幸运的是,现在我们可以很轻松地做到这一点。Level.js
<p>
{item.name} position: {item.row}, {item.col}
</p>
<button className={activeLevel.done ? 'button': 'hidden'}>
Next
</button>
然后创建这些类index.css
.hidden {
display: none;
}
.button {
background: #13293d;
color: #3c6e71;
border-radius: 5px;
}
这几乎达到了我们的预期效果。“下一步”按钮会显示,但不会在关卡状态改变时显示。它只有在我们向编辑器中输入另一个字符后才会显示。
我们将在下一篇文章中解决这个问题。无论如何,我们的状态已经有点混乱了,所以我们需要进行一次重构。
在结束这篇文章之前,让我们让我们的<AceEditor>组件更易于使用一些。
调整编辑器
我们提供给玩家的` log,`、validInput`,` 和 ` moveItem` 函数虽然不长,但仍然占用了不少空间。对于玩家来说,这并不是一个简洁的界面。
我们可以利用react-ace代码折叠功能,并onLoad在我们的editor.js文件中添加一个函数,来稍微清理一下代码:
export const onLoad = (editor) => {
editor.session.foldAll();
editor.getSession().setUseWrapMode(true);
editor.setOption("showLineNumbers", false);
};
然后将其连同useEffect其他文件一起导入。App.js
import React, { useState, useRef, useEffect } from "react";
import editorValue, { onLoad } from "./editor";
const editorRef = useRef(null);
useEffect(() => {
onLoad(editorRef.current.editor);
});
return(
// add the ref to <AceEditor>
<AceEditor ref={editorRef} />
)
好多了!
document.getElementById()每次moveItem()测试应用时都要手动输入命令,这确实有点繁琐。我们将使用自动完成功能react-ace来缓解这个问题:
<AceEditor
ref={editorRef}
mode="javascript"
theme="dracula"
onChange={onChange}
width="auto"
height="100%"
name="UNIQUE_ID_OF_DIV"
value={value}
editorProps={{ $blockScrolling: true }}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
}}
/>
嗯……没有任何变化。这是因为我们需要安装相应的brace软件包才能使这些功能正常工作:
npm install brace
然后我们可以进行以下两个导入操作。App.js
import "brace/ext/language_tools";
import "ace-builds/webpack-resolver";
甜的!
包起来
我们已经做了很多工作,但还有很长的路要走。下一节,我们将处理进入下一关的流程,并清理我们的状态。这将使我们能够在玩家尝试将物品移动到已被占用的槽位时检测并纠正碰撞。
感谢你坚持看到最后。希望下次还能见到你!
文章来源:https://dev.to/sharifelkassed/building-an-rpg-style-inventory-with-react-part-1-2k8p