1. 目标
一分钟实现游戏动画
2. 游戏动画基本要素
在写代码之前,我们先来了解一下游戏动画的基本要素。
云顶山,开源仙门。
一群练气期弟子聚在一起。
王富贵:“既然是是动画,它肯定能动!”
张平安:“😓,听君一席话,如听一席话,动画是多张图片,或者说多帧之间不停地切换。”
李吉祥:“👏🏻,张师兄说的有理,动画还应该具备帧率,游戏帧率控制在60帧,大多数动画不需要这么高的帧率,10帧左右即可。”
王富贵:“哈哈,刚刚我和大家开玩笑呢,我也想到了一点,角色应该具备状态,比如站立、行走、跳跃等等,当状态改变时,动画也随之改变。”
屏幕前的你立刻一拍大腿,觉得这三人说的很有道理。
于是,你默默地移动鼠标,往下翻开了代码的具体实现……
3. 具体实现
稍微改造一下测试类,为了可读性,我们这里抽取出来三个方法init、update、render,这三个方法只关注游戏逻辑怎么写。
package com.sandbox;
import com.pinea.event.PineaEvent;
import com.pinea.render.PineaImage;
import com.pinea.shape.PineaRect;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// 导入 Pinea 引擎核心类的静态方法
import static com.pinea.core.Pinea.*;
public class Main {
long startTime; // 记录程序启动时间,用于动画帧计算
// 构造方法:初始化游戏窗口
public Main() {
pineaInit("PineaTest", 800, 600); // 初始化窗口,标题和尺寸
startTime = System.currentTimeMillis(); // 记录启动时间
init(); // 初始化游戏资源
}
// 游戏主循环
public void run() {
PineaEvent event = new PineaEvent(); // 事件对象,用于处理输入
// 时间控制变量,用于稳定帧率
long lastTime = System.nanoTime();
long accTime = 0;
long currentTime;
long deltaTime = 1000_000_000 / 60; // 60FPS 的每帧纳秒数
float deltaTimeF = 1f / 60f; // 每帧的时间(秒)
pineaShow(); // 显示窗口
boolean running = true;
while (running) { // 主循环
// 处理事件队列
while (pineaPollEvent(event)) {
if (event.type == PINEA_EVENT_QUIT) { // 窗口关闭事件
running = false;
} else if (event.type == PINEA_EVENT_KEY_DOWN) { // 按键按下
Input.update(event.key, true);
} else if (event.type == PINEA_EVENT_KEY_UP) { // 按键释放
Input.update(event.key, false);
}
}
// 帧率控制:固定 60FPS 更新
currentTime = System.nanoTime();
accTime += currentTime - lastTime;
lastTime = currentTime;
if (accTime >= deltaTime) {
while (accTime >= deltaTime) {
accTime -= deltaTime;
update(deltaTimeF); // 更新游戏逻辑
}
render(); // 渲染画面
pineaRender(); // 提交渲染
}
}
pineaQuit(); // 退出游戏
}
// 游戏资源
PineaImage bgImage; // 背景图片
Map<String, List<PineaImage>> playerAnimations; // 玩家动画集合(状态 -> 帧列表)
PineaRect playerPosition; // 玩家位置和大小
String playerState; // 玩家当前状态(idle/run/jump)
boolean flipX; // 玩家是否水平翻转
// 初始化游戏资源
private void init() {
bgImage = pineaLoadImage("assets/images/bg.png"); // 加载背景图
// 加载玩家图片并切割动画帧
PineaImage playerImage = pineaLoadImage("assets/images/mario.png");
playerAnimations = new HashMap<>();
// idle动画帧
playerAnimations.put("idle", Arrays.asList(
playerImage.crop(16 * 6, 16 * 2, 16, 16).scale(2.68f, 2.68f)
));
// idle( flip)动画帧
playerAnimations.put("flip_idle", Arrays.asList(
playerImage.crop(16 * 6, 16 * 2, 16, 16).scale(2.68f, 2.68f).flip(true, false)
));
// 跑步动画帧
playerAnimations.put("run", Arrays.asList(
playerImage.crop(16 * 0, 16 * 2, 16, 16).scale(2.68f, 2.68f),
playerImage.crop(16 * 1, 16 * 2, 16, 16).scale(2.68f, 2.68f),
playerImage.crop(16 * 2, 16 * 2, 16, 16).scale(2.68f, 2.68f)
));
// 跑步( flip)动画帧
playerAnimations.put("flip_run", Arrays.asList(
playerImage.crop(16 * 0, 16 * 2, 16, 16).scale(2.68f, 2.68f).flip(true, false),
playerImage.crop(16 * 1, 16 * 2, 16, 16).scale(2.68f, 2.68f).flip(true, false),
playerImage.crop(16 * 2, 16 * 2, 16, 16).scale(2.68f, 2.68f).flip(true, false)
));
// 跳跃动画帧
playerAnimations.put("jump", Arrays.asList(
playerImage.crop(16 * 4, 16 * 2, 16, 16).scale(2.68f, 2.68f)
));
// 跳跃( flip)动画帧
playerAnimations.put("flip_jump", Arrays.asList(
playerImage.crop(16 * 4, 16 * 2, 16, 16).scale(2.68f, 2.68f).flip(true, false)
));
playerState = "idle"; // 初始状态为 idle
playerPosition = new PineaRect(0, 0, 0, 0); // 初始位置
}
// 更新游戏逻辑
private void update(float deltaTimeF) {
playerState = "idle"; // 默认状态为 idle
// 根据按键更新玩家状态和位置
if (Input.isKeyPressed(PINEA_KEY_A)) { // 左移
playerPosition.x -= 250.f * deltaTimeF;
playerState = "run";
flipX = true;
}
if (Input.isKeyPressed(PINEA_KEY_D)) { // 右移
playerPosition.x += 250.f * deltaTimeF;
playerState = "run";
flipX = false;
}
if (Input.isKeyPressed(PINEA_KEY_W)) { // 上移
playerPosition.y -= 250.f * deltaTimeF;
playerState = "run";
}
if (Input.isKeyPressed(PINEA_KEY_S)) { // 下移
playerPosition.y += 250.f * deltaTimeF;
playerState = "run";
}
if (Input.isKeyPressed(PINEA_KEY_SPACE)) { // 跳跃
playerState = "jump";
}
// 如果翻转,后续就播放翻转动画
if (flipX) {
playerState = "flip_" + playerState;
}
}
// 渲染画面
private void render() {
pineaDrawImage(bgImage, 0, 0); // 绘制背景
// 根据当前状态获取动画帧列表
List<PineaImage> pineaImages = playerAnimations.get(playerState);
// 计算当前应该显示的帧索引(基于运行时间)
long spendTime = System.currentTimeMillis() - startTime;
int index = (int) spendTime / 100 % pineaImages.size();
// 绘制玩家当前帧
pineaDrawImage(pineaImages.get(index), playerPosition.x, playerPosition.y);
}
// 程序入口
public static void main(String[] args) {
new Main().run();
}
}
保存代码,运行结果如下:
张平安、李吉祥立刻震惊,不约而同发出感叹:“还真是牛犊子从哪里来啊!!”
王富贵疑惑:“什么意思?”
张平安解释:“🐮🍺”
王富贵:“emm…”
就在这时,一道轰隆声响彻天际,云顶山突然剧烈地摇晃起来。
远处天空,有裂纹蔓延而开,裂隙间,漆黑之物若泼墨倒灌向这片大地,状如飞瀑,颇为震撼。
张平安微微一怔:“不好,“赞印”和“藏印”过少,低维空间的封印有所松动。”
片刻后,只见他缓缓抬起一根手指,突然指向屏幕外正在阅读此文章的大帅比:“快救救我,哥们。另外,帮我敲打一下作者,我怀疑他有凑字数的嫌疑。。。”
3. To be Continued…
本节已结束。
我们已经通过Swing实现了自己的游戏库,得益于Java的跨平台性,Pinea可以运行在Windows、Mac、linux上。
于是,你有了更多的野心:我不仅要跨平台,还要跨语言,还要性能好,还要……
想要跨语言,底层最好是C/C++库,不管是Lua、Java、Python还是C#,都能直接或间接的调用C/C++库。
SDL3是一个纯C实现、性能好且稳定的库。
接下来,我们将大刀阔斧、釜底抽薪、心如刀绞地用Java Native形式,来实现游戏的底层逻辑,换句话说,我们做SDL3的Java绑定。
年轻不折腾,折腾你到老😏——偷偷学编程。