news 2026/9/15 17:17:27

Java Swing游戏开发入门:黄金矿工项目中的循环、物理与调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Swing游戏开发入门:黄金矿工项目中的循环、物理与调试

简介:面向Java初学者打造的黄金矿工游戏开发资料,完整涵盖了基于Java Swing技术从零构建经典桌面小游戏的源代码与配套视频教程。整个资源包以rar压缩包分发,共包含664个文件,压缩后体积约257.84MB。其中Java源文件120个、编译后的class文件164个,26段mp4视频教程,以及92个png、69个gif、46个jpg等图像素材,另有工程配置文件与说明文档,目录按开发步骤清晰划分,既可直接导入Idea运行,也便于逐模块对照学习。视频教程采用手把手演示方式,可引导读者一行一行编写代码,深入体会窗口绘制、事件监听、碰撞检测、定时器刷新等Swing核心技术,同时积累小型游戏项目的整体设计思路。因此既适合刚掌握Java基础语法、想通过项目进阶桌面应用开发的初学者,也适合以游戏开发为毕业设计课题的高校学生,对准备面试作品的学生亦有帮助。目前该资源已有111人浏览学习,是低成本入门Java图形界面编程的可靠参考。

1. 一个 .rar 里的 Java 游戏项目,真正值钱的是这三样东西

“Java开发黄金矿工游戏.rar”这种命名方式很典型,你解压之后大概率会看到 src 目录、class 文件、可能还有一张运行截图,但整个包能不能通过验收,取决于写的人有没有把三件事做对:事件循环和绘制是否分离、钩子的物理状态机是否严谨、矿物对象的生成与碰撞是否可控。这三个点恰好就是 Java 游戏开发的基本功——线程模型、状态机和对象管理。黄金矿工看起来只有“放钩子、抓矿、过关”这么简单,但它比贪吃蛇、俄罗斯方块多出一根一直在摆动的绳索。绳索从摆动、延伸到抓取、拉回、断线,是一套连续的物理状态转换,这才是这个项目真正值得写的地方。适合正在做课程设计的同学,也适合那些把 Swing 和线程背进八股、但没见过实际游戏项目的人。

2. 游戏循环怎么搭:Swing 定时器、画布刷新与线程边界

2.1 Swing 还是 JavaFX:课设要看交付成本,不只看效果

先定框架。黄金矿工是二维画面,视觉元素基本就是矩形、圆形、线段和少量位图,Swing 的 Graphics2D 完全画得出来。同样是画布型应用,JavaFX 有 Canvas、AnimationTimer 和更现代的控件体系,确实写起来更顺手。但如果是课程设计,我一般会选 Swing。理由不是 Swing 更好,而是它的交付成本最低:只需要 JDK,不需要额外下载运行时,也不涉及 JavaFX 从 JDK 11 起不再随 JDK 发行后那一堆 module-info 配置。在小游戏这种规模下,少一个环境变量就是少一个事故源。

比选型更重要的是一条原则:游戏逻辑不要写在 paintComponent 里。paintComponent 只负责把“当前状态”画出来,所有逻辑变化集中在统一的 update 流程里。这么做的价值要等做调试和加功能时才体现得出来——你能暂停、能重跑、能给物理函数单独写测试。如果一上来就把坐标计算、碰撞判定全部塞进绘制方法,后面每一步都是灾难。

2.2 用 JPanel 当画布,把绘制和逻辑分开

public class GamePanel extends JPanel { private final GameState state; private final Hook hook; public GamePanel() { setPreferredSize(new Dimension(800, 600)); setFocusable(true); // 必须加,否则键盘事件收不到 this.state = new GameState(); this.hook = new Hook(400, 75); // 绞盘位置:顶部中间 } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g.create(); g2.setColor(new Color(0xD9, 0xA7, 0x66)); // 泥土色背景 g2.fillRect(0, 60, getWidth(), getHeight() - 60); state.draw(g2); // 先画矿物、炸药等游戏对象 hook.draw(g2); // 再画绳索,保证绳索在最上层 state.drawHud(g2); // 分数、倒计时、目标分 g2.dispose(); } }

这里有两个细节值得记住。第一,setFocusable(true) 不写,JFrame 弹出来后按空格键毫无反应,这是 Swing 键盘事件的常见坑。第二,g2 = (Graphics2D) g.create() 复制了一份图形上下文,之后设置颜色、笔画、抗锯齿都不会污染 Swing 内部状态,结尾再 dispose。这行代码在答辩时经常被问,因为很多实现直接强转 g 然后就开始画。

窗口和生成区域建议按下面的参数定,后面所有坐标逻辑都依赖这一组约定:

区域取值说明
画布尺寸800 × 600setResizable(false) 锁定窗口
HUD 区y 0~60剩余时间、当前分数、关卡目标
绞盘/绳索起点(400, 75)顶部居中的矿工位置
矿物生成区x 60~740,y 170~560避开 HUD 和底部绳索死区

2.3 用 javax.swing.Timer 驱动主循环,别自己抢线程

主循环的正确写法不是 while(true) 加 Thread.sleep,而是用一个 16ms 的 Swing Timer。16ms 对应约 60fps,人眼在这个刷新率下已经觉得画面是流畅的。Timer 的每次回调都发生在事件分发线程 EDT 上,所以 update 和 repaint 天然处于同一个线程,不需要为 gameObjects 这个 List 加锁。

public void start() { long base = System.nanoTime(); timer = new Timer(16, e -> { double t = (System.nanoTime() - base) / 1_000_000_000.0; hook.update(t, state.getMinerals()); state.update(t); repaint(); }); timer.start(); } public void stop() { timer.stop(); }

把 t 用 System.nanoTime 换算成“游戏开始后的秒数”,而不是在回调里对某个 int frame 变量自增,这一点很重要。摆动的正弦计算需要连续时间参数,如果拿帧数去乘 dt,一旦 Timer 因为系统负载抖动,整个摆动会忽快忽慢。用绝对时间作为物理输入,画面就只取决于当下这一刻,不依赖前面丢没丢帧。

Timer 的实际触发间隔并不是精准的 16.00ms,可能在 15ms 到 25ms 之间浮动,这是事件队列机制决定的。对于黄金矿工这种小游戏,可接受。只有当你要做严谨的物理模拟或帧率统计时,才需要换 while + Thread.sleep 的自建循环,并在每帧末尾用 System.nanoTime 计算真实 dt,再用 SwingUtilities.invokeLater 发起重绘。课程设计做到 Timer 这一层已经足够,自己管理线程反而容易把 EDT 搞乱。

另一个常见线程问题是游戏资源预加载。如果关卡开始前要读几十张图片或音频,不要直接在 EDT 里循环读文件,窗口会卡死。常见做法是线程池加载加 CountDownLatch 等待完成:

ExecutorService pool = Executors.newFixedThreadPool(4); CountDownLatch latch = new CountDownLatch(imagePaths.size()); for (String path : imagePaths) { pool.submit(() -> { try { cache.put(path, ImageIO.read(new File(path))); } catch (IOException e) { e.printStackTrace(); } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS); // 主线程等待资源加载完成

这里的 CountDownLatch 就是典型的“等待多个线程都完成”的场景。注意 latch.await 如果写在 EDT 上,加载期间窗口同样会失去响应,所以更稳妥的做法是用 SwingWorker 在后台加载,加载完通过 done() 回到 EDT 刷新画面。

3. 钩子的物理模型:摆动、延伸和断线的完整代码实现

3.1 摆动阶段:用系统时间算角度,不要数帧

钩子是整个游戏唯一的运动主体。它挂在绞盘点上,玩家未操作时绕绞盘做往复摆动。摆动模型用一个正弦函数就够了:角度等于基准角加上振幅乘以 sin(时间系数)。

public class Hook { public enum State { SWING, EXTEND, RETRACT_EMPTY, RETRACT_GRAB } private static final double BASE_ANGLE = Math.PI / 2; // 垂直向下 private static final double SWING_AMPLITUDE = 0.55; // 约 31 度 private static final double SWING_SPEED = 1.45; // 弧度/秒 private static final double INIT_LENGTH = 60; private static final double MAX_LENGTH = 500; private static final double EXTEND_SPEED = 320; private static final double RETRACT_SPEED = 360; private static final double WEIGHT_LIMIT = 50; private final double winchX, winchY; private State state = State.SWING; private double angle, length, tipX, tipY; private double lockedAngle; private Mineral grabbed; public void update(double t, List<Mineral> minerals) { switch (state) { case SWING -> doSwing(t); case EXTEND -> doExtend(minerals); case RETRACT_EMPTY -> doRetractEmpty(); case RETRACT_GRAB -> doRetractGrab(); } } private void doSwing(double t) { angle = BASE_ANGLE + SWING_AMPLITUDE * Math.sin(t * SWING_SPEED); length = INIT_LENGTH; syncTip(); } private void syncTip() { tipX = winchX + length * Math.cos(angle); tipY = winchY + length * Math.sin(angle); } }

角度计算里的关键参数是 t,也就是从游戏开始到当前时刻的秒数。sin(1.45 * t) 的意思是每秒摆动约 1.45 弧度,一个完整来回大约 4.3 秒。振幅 0.55 弧度约等于 31 度,玩家在极端位置按下空格时,钩子能覆盖到画面近 70% 的水平区域。这个范围调大调小直接影响游戏难度:振幅超过 0.7 时,绳索会明显碰到画布两侧,观感很差。

syncTip 做的事很简单:把角度和长度换算成钩尖的 x、y 坐标。以后所有碰撞检测都基于 tipX、tipY 计算,不需要再关心角度到坐标的换算。

3.2 延伸阶段:锁住角度做直线运动,碰撞要留吸附余量

玩家按空格键时,绳索当前角度被锁定,钩子沿这个方向直线射出。这里容易踩的坑是:在 Swing Timer 的多帧更新里,玩家可能在摆动状态下连按两次空格,导致状态被重复触发。所以 startExtend 里必须先判断当前状态只能是 SWING 才允许进入 EXTEND。

public void startExtend() { if (state != State.SWING) return; lockedAngle = angle; state = State.EXTEND; } private void doExtend(List<Mineral> minerals) { length += EXTEND_SPEED * dt; syncTipByLockedAngle(); Mineral hit = hitTest(minerals); if (hit != null) { grabbed = hit; state = State.RETRACT_GRAB; } else if (length >= MAX_LENGTH) { state = State.RETRACT_EMPTY; // 伸出到尽头,空手回收 } } private void syncTipByLockedAngle() { tipX = winchX + length * Math.cos(lockedAngle); tipY = winchY + length * Math.sin(lockedAngle); }

上面的 dt 是每帧时间差,建议在 update 里用相邻两次 System.nanoTime 的差值算出来,单位是秒。这样伸出速度 320 像素/秒不依赖 Timer 的标称周期,即使某帧慢了,同一帧里移动的距离也按真实耗时补偿,手感相对一致。

hitTest 是命中判定的核心:

private static final double GRAB_BONUS = 12; private Mineral hitTest(List<Mineral> minerals) { for (Mineral m : minerals) { if (!m.isAlive()) continue; double dx = tipX - m.getX(); double dy = tipY - m.getY(); double r = m.getRadius() + GRAB_BONUS; if (dx * dx + dy * dy <= r * r) return m; } return null; }

判断用的是钩尖与矿物圆心的距离平方和半径比较,纯浮点运算,一帧内对全部矿物遍历一遍也不会有性能压力。GRAB_BONUS 是吸附余量,把判定半径扩大 12 像素。不加这个余量时,玩家会感觉“明明碰到金子边了却抓不住”,因为钩尖本身只有几个像素,而矿物的视觉边缘和实际判定圆之间没有缓冲。

3.3 拉回与断线:重量影响回收速度,超重直接断绳

抓到矿物后进入拉回阶段。拉回不是匀速的,抓到的物体越重,绳索回收越慢,这是黄金矿工最有手感的参数。重量系数按 0 到 WEIGHT_LIMIT 的比例递减,同时设一个 0.45 的速度下限,否则抓到大岩石时回收速度接近零,玩家会以为游戏死了。

private void doRetractGrab() { double w = grabbed.getWeight(); if (w >= WEIGHT_LIMIT) { grabbed = null; state = State.RETRACT_EMPTY; // 断绳,按空载速度回收 return; } double factor = 1.0 - 0.6 * (w / WEIGHT_LIMIT); double speed = RETRACT_SPEED * Math.max(factor, 0.45); length -= speed * dt; syncTipByLockedAngle(); if (length <= INIT_LENGTH) { state.getMinerals().remove(grabbed); state.addScore(grabbed.getScore()); grabbed = null; state = State.SWING; } }

断线条件写在每帧拉回判断的开头。把 WEIGHT_LIMIT 设为 50,配合矿物重量表,就能做出“金块都能抓、铁箱必然断绳”的体验。断绳后 grabbed 置空、状态切回空载回收,钩子自己回去,被抓住的矿物留在原地。这个过程最好在屏幕上给一个短暂的 “绳索断裂” 提示,比如用 g2.drawString 在绞盘下方闪一下,否则玩家只会觉得莫名其妙。

拉回阶段的数学很简单但很值得细说:factor 取的是 1 减去 0.6 倍的重量占比。重量 14 的中金块 factor 约 0.83,回收速度接近空载;重量 32 的岩石 factor 0.62,速度明显变慢;重量 60 的铁箱直接超过 50 阈值,连减速的机会都没有,直接断绳。这样玩家看到铁箱的第一反应不是去抓它,而是等摆幅过去继续找金矿。

4. 矿物生成与碰撞体系:用枚举把类型、数值和画面全部管起来

4.1 别建一堆子类,用枚举定义矿物类型

小金块、中金块、大金块、岩石、铁箱、钻石、炸药,这几类对象除了名称、半径、重量、分值不同,行为几乎一样。没必要写 GoldMineral、RockMineral 的子类树,一个 Mineral 类加一个 MineralType 枚举就能表达所有差异。这种用数据描述对象、而不是用继承描述对象的思路,本身就是 Java 枚举类型在生产里最常见的用途。

public enum MineralType { GOLD_SMALL("小金块", 10, 8, 10, 0.28), GOLD_MEDIUM("中金块", 18, 14, 30, 0.18), GOLD_LARGE( "大金块", 28, 22, 80, 0.05), ROCK( "岩石", 24, 32, 0, 0.30), IRON_BOX( "铁箱", 27, 60, 40, 0.07), DIAMOND( "钻石", 9, 4, 150, 0.04), TNT_BOX( "炸药", 20, 26, -50, 0.08); private final String label; private final int radius; private final int weight; private final int score; private final double rate; MineralType(String label, int radius, int weight, int score, double rate) { this.label = label; this.radius = radius; this.weight = weight; this.score = score; this.rate = rate; } public Mineral createAt(int x, int y) { return new Mineral(this, x, y); } }

上面的枚举构造参数从左到右是:显示名、半径、重量、分值、生成概率。这个表本身就是游戏平衡性的全部配置,调难度时只需改数值,不用动逻辑代码。重量和绳索 WEIGHT_LIMIT 的联动关系也能一眼看出来:岩石 32 可抓但慢,铁箱 60 必断,炸药 26 能抓但抓完扣 50 分。这种“一眼能看懂整局节奏”的效果,正是枚举比散落的常量强的地方。

Mineral 类本身极简,只保存类型实例、坐标和存活标记:

public final class Mineral { private final MineralType type; private final double x, y; private boolean alive = true; public Mineral(MineralType type, double x, double y) { this.type = type; this.x = x; this.y = y; } public void draw(Graphics2D g2) { // 按 type 的数值配置画圆、多边形或方块 g2.setColor(colorFor(type)); g2.fillOval((int) (x - type.getRadius()), (int) (y - type.getRadius()), type.getRadius() * 2, type.getRadius() * 2); } }

画图时不建议直接引用枚举做五彩缤纷的颜色判断,把颜色也放进枚举或者单独维护一个 Map。写死绿色是炸药、金色是金子这种判断散落得到处都是,后面调画面会很难受。

4.2 生成逻辑:权重随机加最小间隔,种子锁住回归测试

生成矿物要避免两个问题:一是全屏矿物挤成一团,钩子一伸出去连中两个看起来很不真实;二是同一局全是岩石,玩家体验断崖式下跌。解决方式是用带权重的随机选择加最小间距过滤。

public class LevelGenerator { public static List<Mineral> generate(int count, long seed) { Random rnd = new Random(seed); List<Mineral> list = new ArrayList<>(count); int tries = 0; while (list.size() < count && tries < 200) { tries++; int x = 70 + rnd.nextInt(660); int y = 170 + rnd.nextInt(380); MineralType type = randomType(rnd); if (tooClose(list, x, y, 30)) continue; list.add(type.createAt(x, y)); } return list; } private static MineralType randomType(Random rnd) { double p = rnd.nextDouble(); double acc = 0; for (MineralType t : MineralType.values()) { acc += t.getRate(); if (p < acc) return t; } return MineralType.ROCK; } private static boolean tooClose(List<Mineral> list, int x, int y, int minD) { for (Mineral m : list) { double dx = m.getX() - x; double dy = m.getY() - y; if (dx * dx + dy * dy < minD * minD) return true; } return false; } }

randomType 的思路是把每个类型的 rate 当成一个概率区间,累计到 1.0,然后拿随机数在这个区间里落点。前面枚举里 rate 之和正好是 1.0,所以这里不需要额外归一化。最小间隔 30 像素的含义是:两个矿物的圆心距离小于 30 则重新随机,这个值比最大的矿物半径 28 稍大一点,能保证视觉上矿物之间不重叠。

seed 参数值得专门说。上线时你可以用 System.currentTimeMillis() 做随机种子,让每局不一样;但开发调试阶段,把种子固定下来,比如 20250607L,这样每次启动生成的矿物布局完全一致,排错时能复现。等调到满意了,再把它换成当前毫秒数,这就是固定种子在游戏开发里的真实作用。

4.3 关卡循环:倒计时、目标分和难度参数一起动

黄金矿工的核心节奏是限时达标。每关给出一个目标分数和一段时间,时间耗尽时如果分数达标就进下一关,否则游戏结束。下一关的难点在于所有难度参数要联动,而不是只加一个目标分数。

public void startNextLevel(long seed) { level++; targetScore = 500 + (level - 1) * 250; timeLeft = 45 + level * 2; // 每关多给 2 秒 int count = Math.min(18 + level * 2, 34); // 最多 34 个矿物 minerals = LevelGenerator.generate(count, seed + level); }

时间给多了,矿物也要相应增加,否则玩家提前抓完就无事可做。矿物数量封顶 34 是实战经验值:画布 800 × 600,超过这个数量后配合 30 像素最小间隔,再往上加就会出现密集堆叠,画面开始显得杂乱。目标分数和时间的配比建议按一条经验法则控制:前 20 秒能轻松抓到的小金块、中金块加起来应该能达到目标分的 60% 左右,剩下 40% 需要玩家主动去碰钻石和运气好的大金块。如果前 20 秒只靠随手抓就能轻松过关,玩家会觉得关卡没有压力。

倒计时的更新不放在钩子逻辑里,因为它是全局状态。GameState 里用一个 double 字段 timeLeft 每帧减去真实 dt,到 0 时检查 score 和 targetScore 的关系。注意这里的时间也不能用帧数递减,否则 Timer 抖动会直接导致倒计时忽快忽慢,和摆动问题同源。

5. 从“卡住不动”到“手感不对”:黄金矿工的调试与调参

5.1 第一步排查:先确认 update 进了没有,再谈别的

最常见的“游戏卡住”根本不是逻辑错误,而是 update 根本没被调用。我排查时第一件事是在 update 第一行和 paintComponent 第一行各打一个断点,或者打印 System.nanoTime() 的时间戳。如果 update 每 16ms 稳定触发,而画面不动,问题在绘制层;如果 update 压根没触发,检查 Timer 有没有 start,以及 GamePanel 有没有被添加到 JFrame 并调用 setVisible(true)。

第二种常见现象是“钩子伸出后永远不回弹”。先别改代码,看一眼 Hook 的状态停留在哪个枚举值。在 doExtend 里加一行 System.out.println(state) 打印几次,如果状态一直是 EXTEND 且 length 没有变化,大概率是 dt 计算成了 0——更新里的 System.nanoTime 差值是 0,速度乘以 0 自然不动。修法是用 double lastTime 保存上一次的 nanoTime,每帧计算 diff 后立即更新 lastTime,不要用两个静态时间相减。

5.2 手感参数按这个顺序调,不要盲目改代码

感觉“抓不到东西”和“玩起来难受”是两种不同的问题。前者优先看吸附半径,后者看速度和重量系数。下面这张表是我调黄金矿工这类小游戏时惯用的参数区间:

参数建议区间影响
GRAB_BONUS8~14判定半径扩大,越小越精准越难
EXTEND_SPEED260~360伸出过快,玩家来不及反应
RETRACT_SPEED320~400拉回过慢,一局节奏被拖死
SWING_SPEED1.2~1.8摆动周期,1.6 左右最顺手
WEIGHT_LIMIT45~55断绳门槛,决定铁箱和巨岩的地位

调参建议把 GRAB_BONUS 放第一位。它直接影响玩家对“命中”的感知,数值从 8 调到 12,游戏难度感会下降一大截。如果调完吸附半径仍然觉得难,再去动 EXTEND_SPEED。最后才调 WEIGHT_LIMIT,因为它和所有矿物的重量都耦合,动一个数等于重设整张平衡表。

5.3 给物理函数写回归测试,改断线逻辑不再心虚

黄金矿工的物理函数是纯逻辑,不依赖屏幕,非常适合做单元测试。给它写几个 JUnit 用例,比每次手动开窗口按空格键试错快得多。

@Test void 摆动阶段长度不变() { Hook hook = new Hook(400, 75); hook.updateSwing(3.0); assertEquals(60.0, hook.getLength(), 1e-6); } @Test void 超重物体导致立即断绳() { Hook hook = new Hook(400, 75); hook.startExtendWithAngle(0.0); // 测试辅助方法,锁定角度 Mineral iron = new Mineral(MineralType.IRON_BOX, 500, 300); hook.attachTarget(iron); // 模拟抓到铁箱 hook.setState(Hook.State.RETRACT_GRAB); hook.update(0.1, List.of()); assertEquals(Hook.State.RETRACT_EMPTY, hook.getState()); }

第一个测试锁定的是摆动阶段绳索长度不变这个特性,回归时如果它失败,说明有人动了 INIT_LENGTH 或者摆动里误改了长度。第二个测试锁的是断线规则,铁箱重量 60 大于阈值 50,进入拉回后应立即断绳。把这类用例沉淀下来,后面调整手感参数时跑一遍测试,就能在打开游戏之前知道平衡逻辑有没有被改坏。

物理类尽量不依赖 GameState,需要的矿物列表通过方法参数传入。这样设计本身就是可测的边界,也是 Java 项目里“面向接口而非面向实现”的一种体现。写完游戏后留这套测试,答辩时能讲的东西也多一个层次。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 17:17:16

Unity+ROS2机器人仿真全攻略:从环境配置到双向通信

为什么偏偏是Unity&#xff0c;而不是Gazebo&#xff1f;这是我决定用Unity做ROS2机器人仿真之后&#xff0c;被问得最多的问题。Gazebo不是不好&#xff0c;但当你需要高质量画面来展示交互逻辑、想给机器人加上复杂的场景光照、或者要交给非机器人专业的同事评审时&#xff0…

作者头像 李华
网站建设 2026/9/15 17:15:55

python的智能制造导论工业场景模拟第二十一篇:利用历史工艺参数数据训练模型,输入原料属性,自主推荐适配的工艺参数,实现自适应调参。

工艺参数自适应推荐&#xff1a;让机床学会“看料下菜”"去年三季度&#xff0c;我们热处理车间那台真空淬火炉&#xff0c;成了老师傅们的‘吵架现场’。同一种牌号的40Cr齿轮&#xff0c;不同批次的毛坯&#xff0c;硬度、金相组织总有细微差别——有的料偏软&#xff0…

作者头像 李华
网站建设 2026/9/15 17:15:00

OpenSceneGraph源码编译与开发环境搭建:从CMake到第一个Demo

写这篇东西之前&#xff0c;先交代一下背景。OpenSceneGraph&#xff08;后面统称OSG&#xff09;这套基于OpenGL的场景图渲染引擎&#xff0c;在可视化仿真、数字孪生、科学计算可视化、GIS三维展示这些领域里一直有稳定的用户群。我对它的定位一直是“够用、透明、不黑盒”—…

作者头像 李华