前節では、コマンド モードを使用して戦車戦闘マップ シーンの事前描画を実現しました。学習結果をさらに強化するために、このセクションでは、地図の描画と地形データの生成を改善するための実践的なデモを作成します。
描画機能表示
私たちが作成したレンダリングを見てください。
描画機能については、前項を踏まえて右側のブラシ設定パネルを実装しました。描画する風景の種類と描画するサイズを選択できますが、レンガのサイズは選択できますが、8*8
他の種類の風景は選択できないことに注意してください。描画効果を見てください。
サイズ列の最初のボタンを選択すると、対応するシーン タイプ列の一部のシーン ボタンが無効になり、これらの設定が関連していることがわかります。同様に、上記のシーンタイプの設定は、サイズの最初のボタンの選択可能状態にも影響します。描画する他のシーンを選択し続けます。ここで、マップ上にシーンのオーバーレイ描画を実現できます。
描画した領域を消去することもでき、消去した領域ではさまざまな「ストローク」を選択することもできます。
なお、8*8
消去ストロークのサイズを選択した場合、レンガ以外の風景はそのサイズが最小単位となるため消去できず、16*16
消去16*16
ストロークのサイズを置き換えることができる。
最後に、再描画のための元に戻す操作を実装することもできます。
クラスデザイン
上記で実装された描画関数を読んだ後、クラス設計をもう一度見てみましょう。これは私たちが常に守らなければならない原則です。関数がどれほど複雑であっても、オブジェクト指向の思考を持ち、外の世界が理解する詳細をカプセル化するように努めなければなりません知る必要はありませんが、最低限のAPIを外部に提供する必要があります。全体的な設計クラス図を見てください。
前のセクションと比較して、描画コマンド クラスの設計思想について、このセクションではいくつかの最適化を行いました。描画は 2 つの部分に分かれています。地図生成コマンドは、ローカル単位エリアの地形データの生成を担当します。ロジックはツール クラスに渡され、マップ生成コマンドによって生成されたマップ データに基づいて描画が行われます。この種の描画の場合は、別の再描画スレッドを開始して、連続呼び出しMapPanel
のpaintComponent
メソッドで描画します。これを行うには、まず、MyFrame
地図描画コンポーネントを初期化するクラスのメニュー クリック ハンドラー メソッドでスレッドを開始します。
drawMapMenuItem.addActionListener(e -> {
MapPanel mapPanel = new MapPanel(this);
getContentPane().add(mapPanel);
// 绘图面板初始化代码省略
...
// 开启一个不断重绘的线程
Thread t = new Thread(() -> {
while (true) {
try {
Thread.sleep(120);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
repaint();
}
});
t.start();
});
このように、作図領域でマウス カーソルをドラッグすると、マップを生成するコマンドが生成されて実行されます。または、Ctrl
+ Z
(戻る) とCtrl
+ Y
(再開) のキーの組み合わせを押して、合計演算を実行した後undo
、redo
実行します。履歴生成コマンドを使用して地図データを再生成します。描画スレッドは、設定された間隔に従ってマップ パネルを継続的に再描画し、マップ データのインターフェイスを描画し、カーソルとともに移動するペン ヘッドを描画します。中央コンポーネントの redraw メソッドの呼び出しロジックを参照してくださいMapPanel
。
@Override
public void paintComponent(Graphics g) {
super.paintComponent(g);
paintGrids(g);
// 执行地图绘制
DrawOrnamentUtil.draw(g, map, () -> {});
// 如果笔头命令可利用(包含必要景物类型和笔触的两个设置),则执行笔头的绘制
if (drawPenHeadCommand.isAvailable()) drawPenHeadCommand.execute(g);
}
インターフェースの効果は次のとおりです。
前節では、実装した描画コマンドは地図データを生成せずに直接地形を描画していましたが、本節の最適化処理により、マウスのドラッグ&ドロップは地図を生成するコマンドオブジェクトの生成と呼び出しのみを担うようになります。描画は継続的に再描画するためにスレッドに渡されるため、描画エクスペリエンスが向上し、undo
実行redo
中に遅延が発生しません。設計レベルでは、単一責任の設計原則が適切に実装されており、さまざまなクラスやコンポーネントが必要なことを実行できるようになり、全体的な実行効率が向上します。
コマンド関連のクラス
マップデータの生成に関連するクラス設計を見てください。
这里我们基于基本的命令接口Command
实现了一个生成地图数据的命令实现类GeneratorCommand
,在它内部我们以私有的成员变量的形式,封装了执行具体生成任务的Generator
接口依赖(实际执行命令时调用的是依赖对象generator.generate(x, y, width, height, type)
方法)以及跟生成地图相关的坐标位置、生成地图区域的宽高和景物的类型字段。同样我们要对这心信息字段重写equals
和hashcode
方法,以避免在拖拽时对同一个位置和区域生成相同景物的命令对象。
而宏命令MacroCommand
依然负责收集生成命令并提供undo
、redo
、clear
和历史绘制方法,唯一调整的是append
方法增加了一个参数forceAppend
来强制添加命令对象,以修复在被其他景物覆盖的位置再覆盖回原来的景物时的bug:
public boolean append(Command cmd, boolean forceAppend) {
if (cmd != this && (!commands.contains(cmd) || forceAppend)) {
// 省略执行逻辑
...
return true;
}
return false;
}
另外为了优化绘图体验,我们添加了绘制笔头的功能,也就是,光标在绘图板上移动时,有一个跟随的笔头效果,笔头会以设定好的景物类型的颜色和笔触(要绘制的形状)来展示。这里我们以单命令的形式来对其实现的。看下类图:
先定义一个绘制命令接口:
package com.pf.java.tankbattle.pattern.command.map;
import ...
public interface DrawCommand {
void execute(Graphics g);
}
这里的参数为画笔对象,会由MapPanel
类的paintComponent(Graphics g)
方法的入参传入进来。绘制笔头命令实现类DrawPenHeadCommand
中包含了要绘制的坐标位置(x
和y
属性)以及当前笔头的设置对象typeSettings
和penHeadSettings
。
这里提供了一个方法来判断当前的笔头是否设置可用,即,判断这两个属性都要设置上:
public boolean isAvailable() {
return this.typeSettings != null && this.penHeadSettings != null;
}
该类中最主要的就是命令执行方法:
@Override
public synchronized void execute(Graphics g) {
Color color = typeSettings.getColor();
// 最后一个参数设置透明度
g.setColor(new Color(color.getRed(), color.getGreen(), color.getBlue(), 180));
int width = penHeadSettings.getWidth();
int height = penHeadSettings.getHeight();
// 绘制跟随光标内嵌于网格中的笔触
g.fillRect(x, y, width, height);
// 绘制一个显眼的白色边框
g.setColor(Color.lightGray);
g.drawRect(x, y, width, height);
}
该方法的绘制工作依赖了外部的设置对象,注意,这里的坐标x
和y
的值是经过计算的,具体的计算由不同的笔触设置所决定,我们可以在光标在绘图板上移动或者拖动时,从事件对象中获取鼠标指针的当前坐标,再按照当前设置的笔触来计算,具体的计算由笔头设置对象来完成,提供一个给外部调用的方法:
public int[] calcPoint(int x, int y) {
return penHeadSettings.handle(x, y);
}
设置相关的类
再来看设置面板,这里包含了两个设置按钮组:
我们同样采用面向对象的思想来设计设置类并封装设置的参数。这里涉及到的类图:
与第一组按钮对应的,我们设计了一个OrnamentTypeSettings
类,其中包含了景物的类型type
、笔头的颜色color
和对应的按钮组件button
。在其构造器中我们还将传入其位于左侧面板的坐标信息以及按钮的Icon
图片资源,用于button
对象的构造:
public OrnamentTypeSettings(String type, Icon icon, Color color, int x, int y) {
this.type = type;
this.color = color;
this.button = new JButton();
this.button.setIcon(icon);
this.button.setBounds(x, y, 32, 32);
}
再来看笔触设置类PenHeadSettings
,它的属性包括了尺寸信息、对应的设置按钮和一个用于原始坐标转换计算的Handler
接口类型,这个handler
由外部在实例化时作为一个匿名接口实现类对象的入参传入,这里我们实现为一个箭头函数调用的形式。看下完整的类:
package com.pf.java.tankbattle.entity.settings;
import ...
public class PenHeadSettings {
private int width;
private int height;
private JButton button;
private Handler handler;
public PenHeadSettings(int width, int height, Icon icon, int x, int y, Handler handler) {
this.width = width;
this.height = height;
this.button = new JButton();
this.button.setIcon(icon);
this.button.setBounds(x, y, 32, 32);
this.handler = handler;
}
public int[] handle(int x, int y) {
return this.handler.handle(x, y);
}
// 省略width、height和button的getter
...
public interface Handler {
int[] handle(int x, int y);
}
}
有了上述两个与每个按钮对应的设置类后,我们还需要有两个相应的设置组类,来封装当前的设置对象、触发一组按钮的点击事件并在其中实现处理逻辑、启用或者禁用一些按钮以及在切换选中设置后对外进行回调设置。
先来看OrnamentTypeSettingsGroup
设置组类,先看下基本定义:
package com.pf.java.tankbattle.entity.settings;
import ...
public class OrnamentTypeSettingsGroup {
/** 当前的设置,可以为null */
private OrnamentTypeSettings currSettings;
/** 所有设置的列表 */
private List<OrnamentTypeSettings> group;
/** 引用的笔头设置组对象 */
private PenHeadSettingsGroup penHeadSettingsGroup;
/** 触发设置后的回调匿名实现类对象(箭头函数) */
private Callable callable;
// 其他方法暂时省略
...
public void setPenHeadSettingsGroup(PenHeadSettingsGroup penHeadSettingsGroup) {
this.penHeadSettingsGroup = penHeadSettingsGroup;
}
public interface Callable {
void call(OrnamentTypeSettings settings);
}
}
在其构造器中,我们将实例化并添加各个设置按钮以及绑定点击事件和编写事件处理逻辑,具体的代码包含了必要的注释,方便大家理解:
public OrnamentTypeSettingsGroup(JPanel container, Callable callable) {
this.callable = callable;
group = new ArrayList<>();
group.add(new OrnamentTypeSettings("B", new ImageIcon(ResourceMgr.tiles[0]), new Color(199, 81, 17), 10, 40));
// 省略其他按钮的实例化和添加逻辑
...
for (OrnamentTypeSettings settings : group) {
// 在左侧面板中添加按钮对象
container.add(settings.getButton());
// 处理点击事件
settings.getButton().addActionListener(e -> {
// 首先重置当前的设置对象,将选中的边框高亮样式去掉
if (currSettings != null) {
currSettings.getButton().setBorder(BorderFactory.createEmptyBorder());
}
// 如果切换了选中
if (settings != currSettings) {
// 执行切换并高亮显示边框
currSettings = settings;
settings.getButton().setBorder(BorderFactory.createLineBorder(Color.YELLOW, 3));
// 如果当前选中项为Grass、Stone、River或者Ice中的一项,要禁用笔头设置组中的第一个按钮
if ("GSRI".contains(settings.getType())) {
penHeadSettingsGroup.disableSettings(0);
} else {
penHeadSettingsGroup.enableSettings(0);
}
} else { // 反选的情况
currSettings = null;
// 恢复笔头设置组的禁用项
if ("GSRI".contains(settings.getType())) {
penHeadSettingsGroup.enableSettings(0);
}
}
// 进行选中项的外部回调
callable.call(currSettings);
});
}
}
然后,我们提供两个方法来对组中的某些选项进行启用和禁用,这里的参数我们采用了可变长度的int
类型来传入按钮所在列表的索引列表:
public void disableSettings(int... indexArr) {
for (int index : indexArr) {
OrnamentTypeSettings settings = group.get(index);
settings.getButton().setEnabled(false);
// 如果当前选中项被禁用,要取消选中,并回调通知外部组件
if (settings == currSettings) {
currSettings.getButton().setBorder(BorderFactory.createEmptyBorder());
currSettings = null;
callable.call(null);
}
}
}
public void enableSettings(int... indexArr) {
for (int index : indexArr) {
OrnamentTypeSettings settings = group.get(index);
settings.getButton().setEnabled(true);
}
}
而PenHeadSettingsGroup
设置组类的大部分代码实现同上,这里粘贴出一部分关键代码:
package com.pf.java.tankbattle.entity.settings;
import ...
public class PenHeadSettingsGroup {
...
/** 引用的景物类型设置组对象 */
private OrnamentTypeSettingsGroup ornamentTypeSettingsGroup;
...
public PenHeadSettingsGroup(JPanel container, Callable callable) {
this.callable = callable;
group = new ArrayList<>();
// 实例化并添加每种笔触对应的按钮,注意这里通过箭头函数封装了每种笔触对原始坐标进行转换的计算逻辑
group.add(new PenHeadSettings(8, 8, new ImageIcon(ResourceMgr.penHeads[0]), 10, 160, (x, y) -> {
x = x / 8 * 8;
y = y / 8 * 8;
return new int[]{x, y};
}));
// 省略其他按钮的实例化和添加逻辑
...
for (PenHeadSettings settings : group) {
...
settings.getButton().addActionListener(e -> {
// 首先重置当前的设置对象,将选中的边框高亮样式去掉
...
// 如果切换了选中
if (settings != currSettings) {
// 执行切换并高亮显示边框
...
// 如果选中的是第一个按钮,也就是8*8尺寸的笔触,要禁用景物类型设置按钮组中的Grass、Stone、River和Ice
if (settings.getWidth() == 8) {
ornamentTypeSettingsGroup.disableSettings(1, 2, 3, 4);
} else {
ornamentTypeSettingsGroup.enableSettings(1, 2, 3, 4);
}
} else { // 反选的情况
currSettings = null;
// 恢复景物类型设置组的禁用项
if (settings.getWidth() == 8) {
ornamentTypeSettingsGroup.enableSettings(1, 2, 3, 4);
}
}
...
});
}
}
...
}
这部分最后给出MyFrame
类的构造器中对左侧设置面板进行初始化的逻辑:
drawMapMenuItem.addActionListener(e -> {
...
// 绘图工具盒
JPanel toolbox = new JPanel();
toolbox.setLayout(null);
toolbox.setBackground(new Color(238, 238, 238)); // 灰色背景
// 初始化宽度
toolbox.setPreferredSize(new Dimension(200,0));
// 添加到窗体布局的右侧
add(toolbox, BorderLayout.EAST);
// 设置景物类型标题
JLabel ornamentTypeTitle = new JLabel("景物类型");
ornamentTypeTitle.setBounds(10, 10, 80, 20);
toolbox.add(ornamentTypeTitle);
// 创建一个景物类型设置组对象,箭头函数为回调设置
OrnamentTypeSettingsGroup ornamentTypeSettingsGroup = new OrnamentTypeSettingsGroup(toolbox, settings -> {
mapPanel.setTypeSettings(settings);
// 让当前窗体上绑定的键盘事件继续有效
requestFocus();
});
JLabel sizeTitle = new JLabel("尺寸");
sizeTitle.setBounds(10, 130, 40, 20);
toolbox.add(sizeTitle);
// 创建一个画笔设置组对象,箭头函数为回调设置
PenHeadSettingsGroup penHeadSettingsGroup = new PenHeadSettingsGroup(toolbox, settings -> {
mapPanel.setPenHeadSettings(settings);
requestFocus();
});
// 让两组设置相互关联
ornamentTypeSettingsGroup.setPenHeadSettingsGroup(penHeadSettingsGroup);
penHeadSettingsGroup.setOrnamentTypeSettingsGroup(ornamentTypeSettingsGroup);
...
});
MapPanel类
最后再回到我们的绘图板主类上来。我们为该类提供了对DrawPenHeadCommand
类的一个依赖drawPenHeadCommand
。从前面的回调设置我们也可以发现,这里我们将提供两个方法来完成设置:
public void setTypeSettings(OrnamentTypeSettings settings) {
drawPenHeadCommand.setTypeSettings(settings);
}
public void setPenHeadSettings(PenHeadSettings settings) {
drawPenHeadCommand.setPenHeadSettings(settings);
}
MyMouseListener
中触发绘制的方法中包含了我们发起命令生成、变更和执行的最核心的控制逻辑:
class MyMouseListener extends MouseAdapter {
private MapPanel mapPanel;
/** 记录每次拖拽绘制的一批命令的数量 */
private int commandCount;
private int oldX, oldY;
public MyMouseListener(MapPanel mapPanel) {
this.mapPanel = mapPanel;
}
/**
* 更新笔头的落点
* @param e
*/
private void updatePenHead(MouseEvent e) {
int x = e.getX(), y = e.getY();
int[] p = drawPenHeadCommand.calcPoint(x, y);
x = p[0];
y = p[1];
updatePenHead(x, y);
}
private void updatePenHead(int x, int y) {
if (x != oldX || y != oldY) {
synchronized (drawPenHeadCommand) {
drawPenHeadCommand.setX(x);
drawPenHeadCommand.setY(y);
}
oldX = x;
oldY = y;
}
}
@Override
public void mouseMoved(MouseEvent e) {
if (!drawPenHeadCommand.isAvailable()) return;
updatePenHead(e);
}
@Override
public void mousePressed(MouseEvent e) {
// 每次拖拽绘制前先把改计数变量置为0
this.commandCount = 0;
doExecution(e);
}
@Override
public void mouseReleased(MouseEvent e) {
if (this.commandCount > 0) {
historyCmd.appendUndoCommandCount(this.commandCount);
}
}
@Override
public void mouseDragged(MouseEvent e) {
doExecution(e);
}
private void doExecution(MouseEvent e) {
if (!drawPenHeadCommand.isAvailable()) return;
int x = e.getX(), y = e.getY();
// 计算光标拖拽的指针位置坐标内嵌于窗格中的坐标位置
int[] p = drawPenHeadCommand.calcPoint(x, y);
x = p[0];
y = p[1];
int width = drawPenHeadCommand.getWidth();
int height = drawPenHeadCommand.getHeight();
// 检查是否超过边界
if (x < 0 || y < 0 || x + width > MAP_WIDTH || y + height > MAP_HEIGHT) {
return;
}
String type = drawPenHeadCommand.getType();
String existType = map[y / 8][x / 8];
// 处理8*8笔头的绘制限制
if (width == 8 && !existType.equals("E") && !existType.startsWith("B")) {
return;
}
Command cmd = new GeneratorCommand(mapPanel, x, y, width, height, type);
// 第二个参数实现可覆盖绘制
if (historyCmd.append(cmd, !existType.startsWith(type))) {
// 边拖拽边执行地图数据生成
cmd.execute();
this.commandCount++;
}
updatePenHead(x, y);
}
}
代码说明
这里我们提供了两个
updatePenHead
的重载方法,用于光标在移动或者拖拽绘制时在网格中内嵌位置的设置,用两个变量oldX
和oldY
记录了一次移动后的结果,如果位置发生了变动则更新drawPenHeadCommand
中的位置信息,注意,这里的更新要加同步代码块,以确保原子性,同时同步代码块也保证了修改变量的可见性,而我们对DrawPenHeadCommand
中的execute(graphics)
方法同样采用了同步代码来控制,来确保读写是互斥的。这里最核心的是
doExecution
方法,在它其中包含了生成地形命令对象前的校验逻辑,比如笔头是否可用、绘制是否超过边界以及处理8*8
规格的笔头的绘制限制逻辑。在添加生成命令之前时,还要考虑这块区域之前绘制的景物被其他景物覆盖然后再用先前的景物类型进行绘制的bug问题,解决办法是,判断绘制起点的地形元素类型和要绘制的类型不一样,就可以强制覆盖。
最后不要忘了,我们的MapPanel
类要负责每个生成地形命令的最终执行,也就是要实现Generator
接口。生成地形数据的核心逻辑如下:
@Override
public void generate(int x, int y, int width, int height, String type) {
// 转换地图坐标为二维数组的索引
x /= 8;
y /= 8;
// 计算处理单元(16*16像素)的数量
int cols = width / 16;
int rows = height / 16;
if ("E".equals(type)) {
if (cols == 0) {
map[y][x] = "E";
return;
}
} else if ("B".equals(type)) {
if (cols == 0) {
if (y % 2 == 0) {
if (x % 2 == 0) {
type = "B0";
} else {
type = "B1";
}
} else {
if (x % 2 == 0) {
type = "B2";
} else {
type = "B3";
}
}
map[y][x] = type;
return;
}
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
int x2 = x + 2 * j;
int y2 = y + 2 * i;
map[y2][x2] = "E".equals(type) ? "E" : type + "0";
map[y2][x2 + 1] = "E".equals(type) ? "E" : type + "1";
map[y2 + 1][x2] = "E".equals(type) ? "E" : type + "2";
map[y2 + 1][x2 + 1] = "E".equals(type) ? "E" : type + "3";
}
}
}
代码说明
关于地形数据的形式,在前面的小节有图示说明,这里在绘制时要考虑
8*8
的单个地形元素的生成以及以16*16
像素为单元的相邻的4个地形元素的生成逻辑。
最後に、地形データ (2 次元配列の一種) に基づいてString[][]
地図を描画するためのツール クラスのコア メソッドDrawOrnamentUtil
:
public static void draw(Graphics g, String[][] map, Sandwich s) {
...
// 绘制在最里层的景物
List<Ornament> back = new ArrayList<>();
// 绘制在最外层的景物
List<Ornament> front = new ArrayList<>();
for (int i = 0; i < ROWS * 2; i++) {
for (int j = 0; j < COLS * 2; j++) {
// 获取左上角四分之一格子的类型
String type = map[i * 2][j * 2];
// 如果是空白或者砖块类型
if ("E".equals(type) || type.startsWith("B")) {
// 再细化判断四个格子
for (int k = 0; k < 4; k++) {
int x = j * 2, y = i * 2;
if (k % 2 == 1) x++;
if (k > 1) y++;
type = map[y][x];
// 跳过空白的格子
if ("E".equals(type)) continue;
// 添加要绘制的砖块元素
front.add(new Ornament(type, x * 8, y * 8));
}
} else { // 判断16×16格子为其他景物类型
int x = j * 16, y = i * 16;
// 河流和冰川要画在最底层
if (type.startsWith("I") || type.startsWith("R")) {
back.add(new Ornament(type, x, y));
} else {
front.add(new Ornament(type, x, y));
}
}
}
}
draw(g, back);
// 坦克、子弹等在这个回调方法中绘制
s.draw();
draw(g, front);
}
コードの説明
ここでシーンの描画を 2 つの部分に分けていますが、戦車や弾丸を追加すると、描画内容が重なって隠れてしまいます。たとえば、弾丸や戦車は干し草の山に隠すことができますが、氷河や川の上では表示できません。カバーできないため、ここでは前景と背景を分割し、戦車や弾丸などの可動オブジェクトを外部に描画するための匿名コールバック関数を提供します。
要約する
このセクションのコマンド モードのコーディング演習を通じて、コードによる興味深い地図描画を楽しみながら、Java オブジェクト指向の設計思想の実践が心に深く刻み込まれたと思いますので、それを保持してください。次のセクションでは、ビルダー モードを認識して練習し、それを使用して大きな鳥のベースの描画を実現します。さあみんな!