文章目录
- 写在前面
- 第一版雏形
- 项目目录
- 主入口程序
- 全局常量
- 动作
- 动作基本类
- 动作生成者
- 动作执行者
- 事件
- 资源加载器
- 交互平台
写在前面
我的QQ宠物已经读大学啦,每天吃得饱饱的,每次回到家,我学习的时候也会让她也学习,我想着以后大学我一定有更多的时间陪她,可谁知2018年9月15日她却回到了自己的故乡。
时间过得也快,转眼就大三了,这些年也学了不少知识,爱上了动漫《罗小黑战记》,谁知又要停更三年呢?罗小黑说:“我想和小白一起学读书!”,好呀~那就来读书吧!
想过多种语言来编写,比如C#、Python、C++,但还是选择了自己最熟悉的Java,谢谢燕然都护的博客给的思路,打算做一个更完整的桌面宠物,模仿原来QQ宠物的饥饿度、健康值、心情值、金币系统、学习成长系统,结合番剧、电影的故事背景添加法力值等等等等。最终做成一款可安装式的C/S架构的游戏,让喜欢罗小黑战记的人在三年的等待时间内都可以来陪伴他~
第一版雏形
项目目录
使用的IDE是JetBrains Intellij IDEA,新建一个普通的Java项目,这个为项目目录
主入口程序
整个程序从主入口程序进入,下面是HelloHeiApplication类源码:
package org.taibai.hellohei;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.taibai.hellohei.constant.Constant;
import org.taibai.hellohei.event.GlobalEventListener;
import org.taibai.hellohei.img.ResourceGetter;
import org.taibai.hellohei.ui.InterfaceFunction;
import java.io.IOException;
public class HelloHeiApplication extends Application {
/**
* 展示图片的窗口
*/
private ImageView imageView;
private AnchorPane pane;
private InterfaceFunction interfaceFunction;
/**
* 全局事件监听,目前支持拖拽、左键点击反馈
*/
private GlobalEventListener globalEventListener;
private final ResourceGetter resourceGetter = ResourceGetter.newInstance();
@Override
public void start(Stage primaryStage) throws IOException {
primaryStage.initStyle(StageStyle.UTILITY);
primaryStage.setOpacity(0); // 设置父级透明度为0
Stage stage = new Stage();
stage.initOwner(primaryStage); // 将 primaryStage 设置为归属对象,即父级窗口
initImageView();
// 交互功能平台
interfaceFunction = new InterfaceFunction(stage, imageView);
// 面板
pane = new AnchorPane(interfaceFunction.getMessageBox(), interfaceFunction.getImageView());
pane.setStyle("-fx-background:transparent;");
// 开启全局事件
globalEventListener = new GlobalEventListener(stage, imageView, pane);
initStage(stage);
primaryStage.show();
stage.show();
interfaceFunction.setTray(stage); //添加系统托盘
}
public static void main(String[] args) {
launch(args);
}
private void initImageView() {
Image image = resourceGetter.get(Constant.ImageShow.mainImage);
this.imageView = new ImageView(image);
imageView.setX(0);
imageView.setY(0);
imageView.setLayoutX(0);
imageView.setLayoutY(50);
imageView.setFitHeight(Constant.ImageShow.ImageHeight); // 设置图片显示的大小
imageView.setFitHeight(Constant.ImageShow.ImageWidth);
imageView.setPreserveRatio(true); // 保留width:height比例
imageView.setStyle("-fx-background:transparent;"); // 透明背景
}
private void initStage(Stage stage) {
Scene scene = new Scene(pane, 400, 400);
scene.setFill(null);
scene.getStylesheets().add(getClass().getResource("application.css").toExternalForm());
stage.setScene(scene);
// 设置窗体的初始位置
stage.setX(850);
stage.setY(400);
stage.setAlwaysOnTop(true);// 窗口总显示在最前
// 修改任务栏图标
stage.getIcons().add(resourceGetter.get(Constant.ImageShow.iconImage));
stage.initStyle(StageStyle.TRANSPARENT);// 背景透明
stage.setOnCloseRequest(event -> {
event.consume();
interfaceFunction.exit();
});
}
}
依次说明一下:
- primaryStage并非正在的stage,在start方法中又新建了一个
Stage
实例,并且让primaryStage隐藏,这样做的目的是就不会在任务栏里面显示进程了,否则强迫症患者会很难受的。 ImageView
将作为整个程序展示的窗口,一系列动作也只是替换gif图片罢了- 交互平台
InterfaceFunction
提供了用户的一系列交互动作,例如显示隐藏、退出、切换状态,以后的各种功能也将在交互平台扩展 - 全局事件监听者
GlobalEventListener
:考虑到各事件之间可能会相互干扰,于是开了一个类去集中管理。意在解决重复触发点击事件、拖动时不触发点击事件等问题。这样也让start方法更轻便一些。 - 最后将交互平台加入系统托盘,于是你可以在任务栏里像找到QQ程序一样找到
小黑后台
全局常量
虽然全局常量不太好,目前功能单一,全局常量有助于调试,希望在后面能选择更好的解决方案
package org.taibai.hellohei.constant;
/**
* <p>Creation Time: 2021-09-21 18:00:49</p>
* <p>Description: 各种常量,集中管理</p>
*
* @author 太白
*/
public class Constant {
public static class ImageShow {
/**
* 主体的长与宽
*/
public static final int ImageHeight = 100;
public static final int ImageWidth = 100;
public static final String mainImage = "/org/taibai/hellohei/img/licking the claw.gif";
public static final String byeImage = "/org/taibai/hellohei/img/bye.gif";
public static final String iconImage = "/org/taibai/hellohei/img/icon.png";
public static final String guitarImage = "/org/taibai/hellohei/img/playing guitar.gif";
}
public static class UserInterface {
/**
* 交互时间,例如点击罗小黑会响应一个动作,该动作持续RunTime
*/
public static final int RunTime = 3;
/**
* 碎碎念
*/
public static final String[] selfTalking = {
"嘿咻~",
"点我~",
"小白,这个字怎么念呀",
"想吃甘蔗了……",
"在干嘛呢~"
};
}
}
动作
动作基本类
一个动作应该有如下属性
- path: 该动作是什么
- time: 执行多少时间
- isTemporaryAction: 是否是暂时的,比如点击后触发的动作是展示显示的,而恢复到默认状态是持续的
- recoverPath: 如果是暂时的那么应该恢复到什么动作
- interruptable: 是否可中断的,例如退出动画是不可中断的,而在做普通动画是可中断的,这样退出动画就得以显示
package org.taibai.hellohei.ui;
/**
* <p>Creation Time: 2021-09-22 11:49:27</p>
* <p>Description: 动作</p>
*
* @author 太白
*/
public class Action {
/**
* 当前动作
*/
private final String path;
/**
* 动作维持时间,如果为-1则保持该动作
*/
private final double time;
/**
* 是否是暂时的动作
*/
private final boolean isTemporaryAction;
/**
* 如果是暂时的动作,则应当在该时间内恢复到这个动作
*/
private String recoverPath;
/**
* 是否可中断
*/
private final boolean interruptable;
/**
* 若动作是持续的,则维持时间为 PerpetualTime
*/
public static final double PerpetualTime = -1.0;
private Action(String path, double time, boolean isTemporaryAction, String recoverPath, boolean interruptable) {
this.path = path;
this.time = time;
this.isTemporaryAction = isTemporaryAction;
this.recoverPath = recoverPath;
this.interruptable = interruptable;
}
private Action(String path, double time, boolean isTemporaryAction, boolean interruptable) {
this.path = path;
this.time = time;
this.isTemporaryAction = isTemporaryAction;
this.interruptable = interruptable;
}
/**
* 创建暂时的、可中断的动作
*
* @param path 动作路径
* @param time 持续时间
* @param recoverPath 恢复动作路径
* @return 创建的动作实例
*/
public static Action creatTemporaryInterruptableAction(String path, double time, String recoverPath) {
return new Action(path, time, true, recoverPath, true);
}
/**
* 创建持续的、可中断的动作
*
* @param path 动作路径
* @return 创建的动作实例
*/
public static Action creatContinuousInterruptableAction(String path) {
return new Action(path, PerpetualTime, false, true);
}
/**
* 创建短暂的、不可中断的动作,例如退出动画
*
* @param path 动作路径
* @param time 持续时间
* @param recoverPath 恢复动作路径
* @return 创建的动作实例
*/
public static Action creatTemporaryUninterruptibleAction(String path, double time, String recoverPath) {
return new Action(path, time, true, recoverPath, false);
}
/**
* 创建持续的、不可中断的动作,比较苛刻展示想不到案例
*
* @param path 动作路径
* @return 创建的动作实例
*/
public static Action creatContinuousUninterruptibleAction(String path) {
return new Action(path, PerpetualTime, false, false);
}
public String getPath() {
return path;
}
public double getTime() {
return time;
}
public boolean isTemporaryAction() {
return isTemporaryAction;
}
public String getRecoverPath() {
return recoverPath;
}
public boolean isInterruptable() {
return interruptable;
}
}
并且采纳《Effective Java》“隐藏”了构造函数,并且提供公开的接口来构造四种类型的动作,分别是
creatTemporaryInterruptableAction
:暂时的、可中断的动作creatContinuousInterruptableAction
:持续的、可中断的动作creatTemporaryUninterruptibleAction
:暂时的、不可中断的动作creatContinuousUninterruptibleAction
:持续的、不可中断的动作
动作生成者
一个动作的产生是随机的,如果放在动作类或者动作执行类不太妥当,因此将其独立管理,构建了一个名为ActionGenerator
的动作生成者类
package org.taibai.hellohei.ui;
import org.taibai.hellohei.constant.Constant;
import java.util.HashMap;
import java.util.Map;
/**
* <p>Creation Time: 2021-09-21 18:15:02</p>
* <p>Description: 获取一个新的交互动作以及交互动作的关闭</p>
*
* @author 太白
*/
public class ActionGenerator {
/**
* 动作编号
*/
private int actionIndex = NoAction;
private static final Map<Integer, String> resource = new HashMap<Integer, String>() {{
put(1, Constant.ImageShow.guitarImage);
}};
private static final int MinIndex = 1;
private static final int MaxIndex = 1;
public static final int NoAction = 0;
/**
* 随机生成一个动作编号,这里当动作编号不为0时说明动作还未结束
*
* @return 当且仅当上一个动作未结束时,返回false,且不生成新动作
*/
public boolean generateNewActionIndex() {
if (actionIndex != NoAction) return false;
actionIndex = (int) (Math.random() * (MaxIndex - MinIndex + 1) + MinIndex);
return true;
}
/**
* 结束动作时必须调用该API,约定的
*
* @return 是否关闭,若早已关闭也返回false
*/
public void close() {
actionIndex = NoAction;
}
/**
* 获得动作的GIF资源
*
* @return 动作GIF资源文件相对路径
*/
public String getActionPath() {
if (resource.containsKey(actionIndex))
return resource.get(actionIndex);
return null;
}
}
这里约定一个动作的开启必须要关闭,不关闭将不会再生成动作。
动作执行者
动作的执行是互相影响的,例如连续点击不应该连续触发动作等,因此将其独立出来,构建了一个动作执行者类ActionExecutor
package org.taibai.hellohei.ui;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.util.Duration;
import org.taibai.hellohei.constant.Constant;
import org.taibai.hellohei.img.ResourceGetter;
/**
* <p>Creation Time: 2021-09-22 11:49:12</p>
* <p>Description: 动作执行者</p>
*
* @author 太白
*/
public class ActionExecutor {
private ImageView imageView;
private Action curAction;
private final ResourceGetter resourceGetter = ResourceGetter.newInstance();
private final ActionGenerator actionGenerator = new ActionGenerator();
private static ActionExecutor actionExecutor;
private Timeline timeline;
public static ActionExecutor newInstance(ImageView imageView) {
if (actionExecutor == null) actionExecutor = new ActionExecutor(imageView);
return actionExecutor;
}
private ActionExecutor(ImageView imageView) {
this.imageView = imageView;
}
public boolean execute(Action action) {
// 如果上一个动作不可中断,那么动作执行失败
if (curAction != null && !curAction.isInterruptable()) return false;
Image actionImage = resourceGetter.get(action.getPath());
imageView.setImage(actionImage);
curAction = action;
if (timeline != null) timeline.pause();
// 如果当前动作是暂时的,则还需要恢复到某一个动作
if (action.isTemporaryAction()) {
timeline = new Timeline(new KeyFrame(Duration.seconds(action.getTime()), e -> executeContinuousInterruptableActionAction(action.getRecoverPath())));
timeline.play();
}
return true;
}
public boolean executeClickAction() {
boolean ok = actionGenerator.generateNewActionIndex();
if (ok) {
execute(Action.creatTemporaryInterruptableAction(
actionGenerator.getActionPath(),
Constant.UserInterface.RunTime,
Constant.ImageShow.mainImage));
}
return ok;
}
/**
* 立即执行一个可中断的、持续的动作
*/
private void executeContinuousInterruptableActionAction(String path) {
curAction = null;
timeline = null;
actionGenerator.close();
Action action = Action.creatContinuousInterruptableAction(path);
execute(action);
}
}
动作的执行是影响全局的,因此将其设计为单例模式,这样全局拿到的就是同一个对象。所产生的影响也是全局同步的。
事件
事件起初遇到了点麻烦,就是拖动也会触发点击事件,如果在主入口程序设置会很繁琐,因此我将事件管理划到了一个类中,这样拖动时记录初始坐标,松开鼠标时只需要判断坐标值是不是一样的,如果是一样的就说明在原地,执行点击事件(就是逗小黑玩)。虽然有可能拖动到同一个地方,但用户既然要拖拽肯定是想移动一个位置,所以不大可能回到原来的位置(就算故意移回到原位也很困难是吧~)
package org.taibai.hellohei.event;
import javafx.scene.image.ImageView;
import javafx.scene.layout.AnchorPane;
import javafx.stage.Stage;
import org.taibai.hellohei.constant.Constant;
import org.taibai.hellohei.img.ResourceGetter;
import org.taibai.hellohei.ui.Action;
import org.taibai.hellohei.ui.ActionExecutor;
import org.taibai.hellohei.ui.ActionGenerator;
/**
* <p>Creation Time: 2021-09-22 12:50:52</p>
* <p>Description: 全局事件监听者</p>
*
* @author 太白
*/
public class GlobalEventListener {
private final Stage stage;
private final ImageView imageView;
private final AnchorPane anchorPane;
/**
* 动作执行者,触发的动作需要托付给动作执行者执行
*/
private final ActionExecutor actionExecutor;
private double xOffset = 0;
private double yOffset = 0;
private double preScreenX = 0;
private double preScreenY = 0;
public GlobalEventListener(Stage stage, ImageView imageView, AnchorPane anchorPane) {
this.stage = stage;
this.imageView = imageView;
this.anchorPane = anchorPane;
this.actionExecutor = ActionExecutor.newInstance(imageView);
enableDrag();
enableClick();
}
/**
* 激活拖动
*/
private void enableDrag() {
anchorPane.setOnMousePressed(e -> {
xOffset = e.getSceneX();
yOffset = e.getSceneY();
});
anchorPane.setOnMouseDragged(e -> {
stage.setX(e.getScreenX() - xOffset);
stage.setY(e.getScreenY() - yOffset);
});
}
/**
* 点击随机触发一个动作
*/
private void enableClick() {
imageView.setOnMousePressed(e -> {
preScreenX = e.getScreenX();
preScreenY = e.getScreenY();
});
imageView.setOnMouseReleased(e -> {
if (e.getScreenX() == preScreenX && e.getScreenY() == preScreenY) {
actionExecutor.executeClickAction();
}
});
}
}
资源加载器
整个程序的运作都需要GIF图片的显示,因此需要用一个类去加载GIF图片,这里使用类级别与属性级别的单例模式,降低了创建类所需要的时间,当然如果使用HashMap容易导致内存泄漏,因此使用WeekHashMap
扩展阅读 WeekHashMap
和HashMap一样,WeakHashMap 也是一个散列表,它存储的内容也是键值对(key-value)映射,而且键和值都可以是null。不过WeakHashMap的键是“弱键”。在 WeakHashMap 中,当某个键不再正常使用时,会被从WeakHashMap中被自动移除。更精确地说,对于一个给定的键,其映射的存在并不阻止垃圾回收器对该键的丢弃,这就使该键成为可终止的,被终止,然后被回收。某个键被终止时,它对应的键值对也就从映射中有效地移除了。
package org.taibai.hellohei.img;
import javafx.scene.image.Image;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
/**
* <p>Creation Time: 2021-09-21 18:35:46</p>
* <p>Description: 资源加载器</p>
*
* @author 太白
*/
public class ResourceGetter {
private static final Map<String, Image> images = new WeakHashMap<>();
private static ResourceGetter singleton;
public static ResourceGetter newInstance() {
if (singleton == null) singleton = new ResourceGetter();
return singleton;
}
private ResourceGetter() {
}
public Image get(String path) {
if (!images.containsKey(path)) {
images.put(path, new Image(Objects.requireNonNull(this.getClass().getResourceAsStream(path))));
}
return images.get(path);
}
}
交互平台
目前的交互功能仅仅只有碎碎念、显示隐藏,希望后面能扩充点功能。交互平台开启一个线程,随机事件后触发一次交互功能,比如开启碎碎念功能后,将在随机事件后弹出消息框。
package org.taibai.hellohei.ui;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Polygon;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.taibai.hellohei.constant.Constant;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Objects;
import java.util.Random;
/**
* <p>Creation Time: 2021-09-21 19:00:47</p>
* <p>Description: 交互功能</p>
*
* @author 太白
*/
public class InterfaceFunction {
private final ImageView imageView;
private final ActionExecutor actionExecutor;
private final Stage stage;
private VBox messageBox;
private CheckboxMenuItem itemSay = new CheckboxMenuItem("碎碎念");
private final String greet = "好久不见鸭,想你了~";
public InterfaceFunction(Stage stage, ImageView imageView) {
this.stage = stage;
this.imageView = imageView;
this.actionExecutor = ActionExecutor.newInstance(imageView);
this.messageBox = new VBox();
initMessage();
say(greet, 8);
// 开启随机事件
RandomEvent randomEvent = new RandomEvent();
new Thread(randomEvent).start();
}
/**
* 初始化消息框
*/
private void initMessage() {
Label bubble = new Label();
//设置气泡的宽度。如果没有这句,就会根据内容多少来自适应宽度
bubble.setPrefWidth(100);
bubble.setWrapText(true); //自动换行
bubble.setStyle("-fx-background-color: rgba(255,255,255,0.7); -fx-background-radius: 8px;");
bubble.setPadding(new Insets(7)); //标签的内边距的宽度
bubble.setFont(new javafx.scene.text.Font(14));
bubble.setTextFill(Color.web("#000000"));
Polygon triangle = new Polygon(0.0, 0.0, 8.0, 10.0, 16.0, 0.0);//分别设置三角形三个顶点的X和Y
triangle.setFill(new Color(1, 1, 1, 0.7));
// VBox.setMargin(triangle, new Insets(0, 50, 0, 0));//设置三角形的位置,默认居中
messageBox.getChildren().addAll(bubble, triangle);
messageBox.setAlignment(Pos.BOTTOM_CENTER);
messageBox.setStyle("-fx-background:transparent;");
//设置相对于父容器的位置
messageBox.setLayoutX(0);
messageBox.setLayoutY(0);
messageBox.setVisible(true);
}
/**
* 退出
*/
public void exit() {
// 展示告别动画
double time = 1.5;
actionExecutor.execute(Action.creatTemporaryUninterruptibleAction(Constant.ImageShow.byeImage, time, Constant.ImageShow.mainImage));
// 要用Platform.runLater,不然会报错Not on FX application thread;
Platform.runLater(() -> say("再见~", Constant.UserInterface.SayingRunTime));
// 动画结束后执行退出
new Timeline(new KeyFrame(
Duration.seconds(time),
ae -> System.exit(0)))
.play();
}
/**
* 说一句话
*
* @param msg 消息
* @param duration 持续时间
*/
public void say(String msg, int duration) {
Label lbl = (Label) messageBox.getChildren().get(0);
lbl.setText(msg);
messageBox.setVisible(true);
//设置气泡的显示时间
new Timeline(new KeyFrame(
Duration.seconds(duration),
ae -> {
messageBox.setVisible(false);
}))
.play();
}
/**
* 添加系统托盘
*
* @param stage 舞台
*/
public void setTray(Stage stage) {
SystemTray tray = SystemTray.getSystemTray();
//托盘图标
BufferedImage image;
try {
// 为托盘添加一个右键弹出菜单
PopupMenu popMenu = new PopupMenu();
popMenu.setFont(new Font("微软雅黑", Font.PLAIN, 14));
MenuItem itemShow = new MenuItem("显示");
itemShow.addActionListener(e -> Platform.runLater(() -> stage.show()));
MenuItem itemHide = new MenuItem("隐藏");
// 要先setImplicitExit(false),否则stage.hide()会直接关闭stage
// stage.hide()等同于stage.close()
itemHide.addActionListener(e -> {
Platform.setImplicitExit(false);
Platform.runLater(stage::hide);
});
MenuItem itemExit = new MenuItem("退出");
itemExit.addActionListener(e -> exit());
popMenu.add(itemSay);
popMenu.addSeparator();
popMenu.add(itemShow);
popMenu.add(itemHide);
popMenu.add(itemExit);
//设置托盘图标
image = ImageIO.read(Objects.requireNonNull(getClass().getResourceAsStream(Constant.ImageShow.iconImage)));
TrayIcon trayIcon = new TrayIcon(image, "小黑", popMenu);
trayIcon.setToolTip("小黑");
trayIcon.setImageAutoSize(true);//自动调整图片大小。这步很重要,不然显示的是空白
tray.add(trayIcon);
} catch (IOException | AWTException e) {
e.printStackTrace();
}
}
public ImageView getImageView() {
return imageView;
}
public VBox getMessageBox() {
return messageBox;
}
class RandomEvent implements Runnable {
@Override
public void run() {
while (true) {
Random rand = new Random();
//随机发生自动事件,以下设置间隔为9~24秒。要注意这个时间间隔包含了动画播放的时间
long time = (rand.nextInt(15) + 10) * 1000;
if (itemSay.getState()) {
//随机选择要说的话。因为目前只有两个宠物,所以可以用三目运算符
String str = Constant.UserInterface.selfTalking[rand.nextInt(5)];
Platform.runLater(() -> say(str, Constant.UserInterface.SayingRunTime));
}
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
之后功能还会继续扩充,苦命考研狗,先去学习了~