Compare commits
59 Commits
61b8db8a20
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e8c95ba99 | |||
| 4f03408682 | |||
| cee525b82e | |||
| 4f486b367f | |||
| f5d5939f29 | |||
| 21ee67a91e | |||
| e8ad2f407c | |||
| 552e0cb899 | |||
| cd3fff70fd | |||
| 770f3667e3 | |||
| 072b074b28 | |||
| 7153ac9b97 | |||
| 8e07b3204a | |||
| 64dc9e649f | |||
| 3b053514ff | |||
| e67428431f | |||
| be5f00c313 | |||
| 64d3815ddd | |||
| 10238fa953 | |||
| 4947ebb29d | |||
| 659827bcc3 | |||
| 5f080713f8 | |||
| 7a47759cf4 | |||
| e3d34f001b | |||
| 7c26fd7ce6 | |||
| 3dcb067a3d | |||
| a1d07210a0 | |||
| b05b13e499 | |||
| 3362cacc72 | |||
| 8723b1354d | |||
| 053d29398e | |||
| 78cf5ffb1b | |||
| 87cfe5aed6 | |||
| 3b9d794163 | |||
| 105c142abb | |||
| f999824300 | |||
| 5eb0e99c70 | |||
| 566d570074 | |||
| ffaa54b339 | |||
| ca33885f02 | |||
| 4f3a75a5a9 | |||
| dae9b8a2c6 | |||
| 558c42e71d | |||
| 5ccd0cbfa9 | |||
| 4b7ad7ced6 | |||
| feaaad496f | |||
| 3cb0792670 | |||
| 8c9d9c7ddc | |||
| 7b182bf8bd | |||
| d1bcab99c0 | |||
| bc144d7929 | |||
| abfc9d124a | |||
| 99b31c974f | |||
|
|
ad4260e401 | ||
|
|
4b31ec1526 | ||
|
|
aab0519ea8 | ||
|
|
5c31c80c07 | ||
|
|
efce7c8a64 | ||
|
|
c34abe68ed |
BIN
.gitignore
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
13
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<annotationProcessing>
|
||||
<profile name="Maven default annotation processors profile" enabled="true">
|
||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<module name="game" />
|
||||
</profile>
|
||||
</annotationProcessing>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/jarRepositories.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RemoteRepositoriesConfiguration">
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Central Repository" />
|
||||
<option name="url" value="https://repo.maven.apache.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Maven Central repository" />
|
||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="jboss.community" />
|
||||
<option name="name" value="JBoss Community repository" />
|
||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="11" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"kingleo.qwen"
|
||||
]
|
||||
}
|
||||
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.debug.settings.onBuildFailureProceed": true
|
||||
}
|
||||
26
README.md
@@ -1,5 +1,27 @@
|
||||
# 有关本项目的说明
|
||||
本项目用于AVCEIT2025届软件技术专业软件241班Java实训小组项目
|
||||
|
||||
作者:如题
|
||||
本项目为AVCEIT2025届软件技术专业软件241班Java实训小组项目
|
||||
This project is about Java Project Training 2025, Major.Software Technology
|
||||
|
||||
### 本项目目前需要做的
|
||||
|
||||
- ~~实现游戏主界面,添加素材~~
|
||||
- ~~完成游戏的核心功能,即双人格斗~~
|
||||
- ~~实现基本的局域网内联机游戏功能~~
|
||||
- *~~打则死路一条~~*
|
||||
- 完善游戏素材
|
||||
- 在操作上实现部分优化
|
||||
|
||||
### 目前正在做的
|
||||
|
||||
- ~~编写单个角色并实现其状态~~ (@wsj)
|
||||
- 完善游戏素材 (@wsj)
|
||||
- ~~给游戏进行界面类添加BUG~~ (@mloluyu)
|
||||
- ~~给标题界面类添加BUG (@mloluyu)
|
||||
- ~~寻找一个合适的数据处理类编写方法 (@wsj)~~
|
||||
- *~~丰矿地打则~~*
|
||||
|
||||
### 参与项目的人
|
||||
|
||||
- 武术家 @wsj
|
||||
- 大货车 @mloluyu
|
||||
|
||||
71
dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>uno.mloluyu</groupId>
|
||||
<artifactId>game</artifactId>
|
||||
<name>game</name>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<description>LibGDX Desktop Game</description>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<release>${maven.compiler.source}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<mainClass>${exec.mainClass}</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.3</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>true</createDependencyReducedPom>
|
||||
<minimizeJar>false</minimizeJar>
|
||||
<shadedArtifactSet />
|
||||
<transformers>
|
||||
<transformer>
|
||||
<mainClass>${exec.mainClass}</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
<Build-Time>${maven.build.timestamp}</Build-Time>
|
||||
</manifestEntries>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<properties>
|
||||
<exec.mainClass>uno.mloluyu.desktop.DesktopLauncher</exec.mainClass>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<gdx.version>1.12.1</gdx.version>
|
||||
<maven.build.timestamp.format>yyyy-MM-dd'T'HH:mm:ss</maven.build.timestamp.format>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
207
pom.xml
@@ -1,56 +1,159 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>uno.mloluyu</groupId>
|
||||
<artifactId>game</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx</artifactId>
|
||||
<version>1.12.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-backend-lwjgl3</artifactId>
|
||||
<version>1.12.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-platform</artifactId>
|
||||
<version>1.12.1</version>
|
||||
<classifier>natives-desktop</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<compilerArgs>--enable-preview</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<configuration>
|
||||
<mainClass>uno.mloluyu.desktop.Launcher</mainClass> <!-- 替换为你的桌面启动器完整类名 -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<groupId>uno.mloluyu</groupId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<gdx.version>1.12.1</gdx.version>
|
||||
</properties>
|
||||
</project>
|
||||
<artifactId>game</artifactId>
|
||||
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<gdx.version>1.12.1</gdx.version>
|
||||
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Core LibGDX -->
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx</artifactId>
|
||||
<version>${gdx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- LWJGL2 Desktop Backend (使用 LwjglApplication 而不是 Lwjgl3Application) -->
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-backend-lwjgl</artifactId>
|
||||
<version>${gdx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 平台原生库(桌面) -->
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-platform</artifactId>
|
||||
<version>${gdx.version}</version>
|
||||
<classifier>natives-desktop</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- FreeType 字体扩展 -->
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-freetype</artifactId>
|
||||
<version>${gdx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-freetype-platform</artifactId>
|
||||
<version>${gdx.version}</version>
|
||||
<classifier>natives-desktop</classifier>
|
||||
</dependency>
|
||||
<!-- JUnit 5 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- gdx-ai 行为树 / FSM / Steering -->
|
||||
<dependency>
|
||||
<groupId>com.badlogicgames.gdx</groupId>
|
||||
<artifactId>gdx-ai</artifactId>
|
||||
<version>1.8.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- Enforcer 防止依赖冲突 / 旧 JDK -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce</id>
|
||||
<goals><goal>enforce</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireJavaVersion>
|
||||
<version>[21,)</version>
|
||||
</requireJavaVersion>
|
||||
<dependencyConvergence/>
|
||||
</rules>
|
||||
<fail>false</fail>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<!-- 与 properties 中保持一致 -->
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<release>${maven.compiler.release}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<configuration>
|
||||
<mainClass>uno.mloluyu.desktop.DesktopLauncher</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- 添加运行时类路径配置,解决类找不到问题 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<addClasspath>true</addClasspath>
|
||||
<mainClass>uno.mloluyu.desktop.DesktopLauncher</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 运行 JUnit 5 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<configuration>
|
||||
<useSystemClassLoader>false</useSystemClassLoader>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 初步覆盖率,仅生成报告,不设阈值 -->
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.11</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals><goal>prepare-agent</goal></goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals><goal>report</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
</project>
|
||||
|
||||
11
src/main/java/uno/mloluyu/characters/Action.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
public enum Action {
|
||||
IDLE, // 待机状态:角色未执行任何动作,静止或准备中
|
||||
JUMP, // 跳跃状态:角色正在空中跳跃或上升
|
||||
MOVE, // 移动状态:角色正在左右移动
|
||||
ATTACK, // 攻击状态:角色正在执行攻击动作
|
||||
DEFEND, // 防御状态:角色正在格挡或防御中
|
||||
HIT, // 受击状态:角色被攻击命中,进入硬直或受伤动画
|
||||
DEAD // 死亡状态:角色生命值为 0,进入死亡处理
|
||||
}
|
||||
20
src/main/java/uno/mloluyu/characters/ActionStateGuard.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
/** 提供状态切换验证。 */
|
||||
public final class ActionStateGuard {
|
||||
private ActionStateGuard() {
|
||||
}
|
||||
|
||||
public static Action transition(FighterBase fighter, Action next) {
|
||||
Action current = fighter.getCurrentAction();
|
||||
if (current == next)
|
||||
return current;
|
||||
if (!ActionTransitionMap.can(current, next)) {
|
||||
return current;
|
||||
}
|
||||
if (fighter instanceof uno.mloluyu.characters.SimpleFighter sf) {
|
||||
sf.directSetAction(next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** 定义合法动作迁移,供校验/日志使用。 */
|
||||
public final class ActionTransitionMap {
|
||||
private static final Map<Action, Set<Action>> ALLOWED = new EnumMap<>(Action.class);
|
||||
static {
|
||||
allow(Action.IDLE, Action.MOVE, Action.JUMP, Action.ATTACK, Action.DEFEND, Action.HIT, Action.DEAD);
|
||||
allow(Action.MOVE, Action.IDLE, Action.JUMP, Action.ATTACK, Action.HIT, Action.DEAD);
|
||||
allow(Action.JUMP, Action.HIT, Action.ATTACK, Action.DEAD, Action.IDLE, Action.MOVE);
|
||||
allow(Action.ATTACK, Action.HIT, Action.DEAD, Action.IDLE, Action.MOVE);
|
||||
allow(Action.DEFEND, Action.HIT, Action.IDLE, Action.MOVE, Action.DEAD);
|
||||
allow(Action.HIT, Action.IDLE, Action.MOVE, Action.DEAD);
|
||||
allow(Action.DEAD); // 终止状态
|
||||
}
|
||||
|
||||
private static void allow(Action from, Action... tos) {
|
||||
ALLOWED.put(from, new HashSet<>(Arrays.asList(tos)));
|
||||
}
|
||||
|
||||
public static boolean can(Action from, Action to) {
|
||||
return ALLOWED.getOrDefault(from, Collections.emptySet()).contains(to);
|
||||
}
|
||||
}
|
||||
14
src/main/java/uno/mloluyu/characters/AdvancedFighter.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
public class AdvancedFighter extends SimpleFighter {
|
||||
|
||||
public AdvancedFighter(String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attack(String attackType) {
|
||||
super.attack(attackType);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
import com.badlogic.gdx.graphics.g2d.*;
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
import com.badlogic.gdx.utils.Array;
|
||||
import uno.mloluyu.util.SimpleFormatter;
|
||||
|
||||
import java.util.EnumMap;
|
||||
|
||||
public class FighterAnimationManager {
|
||||
private EnumMap<Action, Animation<TextureRegion>> animations = new EnumMap<>(Action.class);
|
||||
private EnumMap<Action, Float> frameDurations = new EnumMap<>(Action.class);
|
||||
private TextureAtlas atlas;
|
||||
private float scaleX = 1.0f;
|
||||
private float scaleY = 1.0f;
|
||||
|
||||
public FighterAnimationManager(TextureAtlas atlas) {
|
||||
this.atlas = atlas;
|
||||
for (Action action : Action.values()) {
|
||||
frameDurations.put(action, 0.1f);
|
||||
}
|
||||
}
|
||||
|
||||
public void loadAnimation(Action action, String prefix, int count, boolean loop) {
|
||||
Array<TextureRegion> frames = new Array<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
String regionName = prefix + SimpleFormatter.addLeadingZeros(i, 3);
|
||||
TextureRegion region = atlas.findRegion(regionName);
|
||||
if (region == null) {
|
||||
throw new IllegalArgumentException("未找到区域: " + regionName);
|
||||
}
|
||||
frames.add(region);
|
||||
}
|
||||
|
||||
Animation<TextureRegion> animation = new Animation<>(frameDurations.get(action), frames);
|
||||
animation.setPlayMode(loop ? Animation.PlayMode.LOOP : Animation.PlayMode.NORMAL);
|
||||
animations.put(action, animation);
|
||||
}
|
||||
|
||||
public void loadLooping(Action action, String prefix, int count) {
|
||||
loadAnimation(action, prefix, count, true);
|
||||
}
|
||||
|
||||
public void loadOneShot(Action action, String prefix, int count) {
|
||||
loadAnimation(action, prefix, count, false);
|
||||
}
|
||||
|
||||
public void setFrameDuration(Action action, float duration) {
|
||||
frameDurations.put(action, duration);
|
||||
Animation<TextureRegion> anim = animations.get(action);
|
||||
if (anim != null) anim.setFrameDuration(duration);
|
||||
}
|
||||
|
||||
public boolean isFinished(Action action, float stateTime) {
|
||||
Animation<TextureRegion> anim = animations.get(action);
|
||||
return anim != null && anim.isAnimationFinished(stateTime);
|
||||
}
|
||||
|
||||
public void render(SpriteBatch batch, Action action, float stateTime, Rectangle hitbox, boolean isFacingRight) {
|
||||
Animation<TextureRegion> anim = animations.get(action);
|
||||
if (anim == null) return;
|
||||
|
||||
TextureRegion frame = anim.getKeyFrame(stateTime, anim.getPlayMode() == Animation.PlayMode.LOOP);
|
||||
if (frame == null) return;
|
||||
|
||||
float frameWidth = frame.getRegionWidth() * scaleX;
|
||||
float frameHeight = frame.getRegionHeight() * scaleY;
|
||||
float drawX = hitbox.x + (hitbox.width - frameWidth) / 2;
|
||||
float drawY = hitbox.y;
|
||||
|
||||
boolean wasFlippedX = frame.isFlipX();
|
||||
frame.flip(!isFacingRight && !wasFlippedX, false);
|
||||
frame.flip(isFacingRight && wasFlippedX, false);
|
||||
|
||||
batch.draw(frame, drawX, drawY, frameWidth / 2, frameHeight / 2, frameWidth, frameHeight, 1f, 1f, 0f);
|
||||
frame.flip(wasFlippedX != frame.isFlipX(), false);
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
if (atlas != null) atlas.dispose();
|
||||
}
|
||||
}
|
||||
47
src/main/java/uno/mloluyu/characters/FighterBase.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/** 公共角色基类:后续 Simple/Advanced 统一继承。 */
|
||||
public abstract class FighterBase {
|
||||
protected String name;
|
||||
protected Action currentAction = Action.IDLE;
|
||||
protected Rectangle hitbox = new Rectangle(0, 0, 64, 128);
|
||||
protected Rectangle attackbox = new Rectangle(0, 0, 80, 80);
|
||||
protected final Color debugColor;
|
||||
|
||||
public FighterBase(String name) {
|
||||
this.name = name;
|
||||
ThreadLocalRandom r = ThreadLocalRandom.current();
|
||||
float rr = 0.35f + r.nextFloat() * 0.65f;
|
||||
float gg = 0.35f + r.nextFloat() * 0.65f;
|
||||
float bb = 0.35f + r.nextFloat() * 0.65f;
|
||||
this.debugColor = new Color(rr, gg, bb, 1f);
|
||||
}
|
||||
|
||||
public Action getCurrentAction() {
|
||||
return currentAction;
|
||||
}
|
||||
|
||||
public Rectangle getHitbox() {
|
||||
return hitbox;
|
||||
}
|
||||
|
||||
public Rectangle getAttackbox() {
|
||||
return attackbox;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Color getDebugColor() {
|
||||
return debugColor;
|
||||
}
|
||||
|
||||
protected void setAction(Action next) {
|
||||
this.currentAction = next;
|
||||
}
|
||||
}
|
||||
489
src/main/java/uno/mloluyu/characters/SimpleFighter.java
Normal file
@@ -0,0 +1,489 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
// 注意:本类使用的是包 uno.mloluyu.characters 下的 Action (IDLE, JUMP, MOVE, ATTACK, DEFEND, HIT, DEAD)
|
||||
// 避免与 uno.mloluyu.characters.character.Action (ATTACK1/2/3...) 混淆
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.Input;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
import uno.mloluyu.network.NetworkManager;
|
||||
import uno.mloluyu.util.GameConstants;
|
||||
|
||||
/**
|
||||
* 简化版角色类,仅包含移动、攻击、受击等基础功能。
|
||||
*/
|
||||
public class SimpleFighter extends FighterBase {
|
||||
|
||||
// 继承: name, currentAction, hitbox, attackbox
|
||||
private float verticalSpeed = 0f; // 垂直速度(跳跃/下落)
|
||||
private boolean isGrounded = true; // 是否着地
|
||||
private Rectangle hitbox = new Rectangle(0, 0, 64, 128); // 碰撞盒
|
||||
private Rectangle attackbox = new Rectangle(0, 0, 80, 80); // 攻击判定盒
|
||||
private boolean isFacingRight = true; // 朝向(右/左)
|
||||
private float speed = GameConstants.MOVE_SPEED; // 目标最大水平移动速度
|
||||
private float velX = 0f; // 当前水平速度(加入加速度与减速)
|
||||
private static final int MAX_HEALTH = 200; // 最大生命值
|
||||
private int health = MAX_HEALTH; // 当前生命值
|
||||
private boolean isAttacking = false; // 是否正在攻击(包含 startup+active+recovery 整体)
|
||||
private boolean attackJustStarted = false; // 本帧是否刚开始(用于跳过首帧计时扣减)
|
||||
private float attackTimer = 0f; // 当前阶段剩余时间
|
||||
// 分阶段(帧数可按类型定制):startup -> active -> recovery
|
||||
|
||||
private enum AttackPhase {
|
||||
STARTUP, ACTIVE, RECOVERY
|
||||
}
|
||||
|
||||
private AttackPhase attackPhase = AttackPhase.STARTUP;
|
||||
// 基础时长(可后续移出到配置)
|
||||
// 降低攻击速度:整体各阶段放大(原值约 *1.6~2)
|
||||
private static final float LIGHT_STARTUP = 0.10f, LIGHT_ACTIVE = 0.10f, LIGHT_RECOVERY = 0.22f;
|
||||
private static final float HEAVY_STARTUP = 0.18f, HEAVY_ACTIVE = 0.16f, HEAVY_RECOVERY = 0.36f;
|
||||
private static final float SPECIAL_STARTUP = 0.20f, SPECIAL_ACTIVE = 0.22f, SPECIAL_RECOVERY = 0.42f;
|
||||
// 当前 attackType 的阶段时长(缓存便于递进)
|
||||
private float curStartup, curActive, curRecovery;
|
||||
// 新增:连续攻击序号(本地,用于避免重复伤害)
|
||||
private int attackSequence = 0;
|
||||
private String lastAttackType = "light"; // 记录最后一次攻击类型,供伤害判定
|
||||
private int lastDamageAppliedSeq = -1; // 已经对目标造成伤害的序号,避免重复
|
||||
// 击退 & 无敌
|
||||
private float knockbackX = 0f;
|
||||
private float knockbackTimer = 0f;
|
||||
private float invulnerableTimer = 0f; // 无敌帧时间(被击中后短暂无敌)
|
||||
private static final float INVULNERABLE_DURATION = 0.3f;
|
||||
private static final float KNOCKBACK_DURATION = 0.12f;
|
||||
// 死亡淡出
|
||||
private float deathFadeTimer = 0f; // 剩余淡出时间(>0 表示正在淡出)
|
||||
private static final float DEATH_FADE_DURATION = 1.2f; // 完全消失所需时间
|
||||
// 防御新增字段
|
||||
private boolean defending = false; // 是否防御中
|
||||
private static final float DEFEND_DAMAGE_FACTOR = 0.25f; // 防御减伤比例
|
||||
private static final float DEFEND_KNOCKBACK_FACTOR = 0.3f; // 防御击退比例
|
||||
// 新增:攻击全局冷却(收招结束到允许下一次攻击的最短间隔)
|
||||
private static final float GLOBAL_ATTACK_COOLDOWN = 0.12f;
|
||||
private float globalAttackCDTimer = 0f;
|
||||
|
||||
public SimpleFighter(String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
public void update(float deltaTime) {
|
||||
// 处理无敌帧计时
|
||||
if (invulnerableTimer > 0) {
|
||||
invulnerableTimer -= deltaTime;
|
||||
if (invulnerableTimer < 0)
|
||||
invulnerableTimer = 0;
|
||||
}
|
||||
// 处理击退
|
||||
if (knockbackTimer > 0) {
|
||||
hitbox.x += knockbackX * deltaTime;
|
||||
knockbackTimer -= deltaTime;
|
||||
if (knockbackTimer <= 0) {
|
||||
knockbackX = 0;
|
||||
}
|
||||
}
|
||||
if (isAttacking) {
|
||||
if (attackJustStarted) {
|
||||
attackJustStarted = false; // 第一帧不扣时间,避免可见阶段提前缩短
|
||||
} else {
|
||||
attackTimer -= deltaTime;
|
||||
}
|
||||
if (attackTimer <= 0f) {
|
||||
// 进入下一阶段
|
||||
switch (attackPhase) {
|
||||
case STARTUP:
|
||||
attackPhase = AttackPhase.ACTIVE;
|
||||
attackTimer = curActive;
|
||||
break;
|
||||
case ACTIVE:
|
||||
attackPhase = AttackPhase.RECOVERY;
|
||||
attackTimer = curRecovery;
|
||||
break;
|
||||
case RECOVERY:
|
||||
isAttacking = false;
|
||||
attackTimer = 0f;
|
||||
attackPhase = AttackPhase.STARTUP;
|
||||
globalAttackCDTimer = GLOBAL_ATTACK_COOLDOWN; // 开始冷却
|
||||
if (currentAction == Action.ATTACK)
|
||||
changeAction(Action.IDLE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateAttackbox("light"); // 非攻击中保持一个默认攻击盒(或可隐藏)
|
||||
}
|
||||
|
||||
// 冷却计时
|
||||
if (globalAttackCDTimer > 0f) {
|
||||
globalAttackCDTimer -= deltaTime;
|
||||
if (globalAttackCDTimer < 0f) globalAttackCDTimer = 0f;
|
||||
}
|
||||
|
||||
if (!isGrounded) {
|
||||
verticalSpeed -= GameConstants.GRAVITY * deltaTime;
|
||||
hitbox.y += verticalSpeed * deltaTime;
|
||||
if (hitbox.y <= GameConstants.GROUND_Y) {
|
||||
hitbox.y = GameConstants.GROUND_Y;
|
||||
verticalSpeed = 0;
|
||||
isGrounded = true;
|
||||
changeAction(Action.IDLE);
|
||||
}
|
||||
}
|
||||
// 死亡淡出计时递减
|
||||
if (!isAlive() && deathFadeTimer > 0f) {
|
||||
deathFadeTimer -= deltaTime;
|
||||
if (deathFadeTimer < 0f)
|
||||
deathFadeTimer = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
public void renderSprite(SpriteBatch batch) {
|
||||
}
|
||||
|
||||
public void renderDebug(ShapeRenderer sr) {
|
||||
sr.setColor(Color.BLUE);
|
||||
sr.rect(hitbox.x, hitbox.y, hitbox.width, hitbox.height);
|
||||
if (isAttacking) {
|
||||
sr.setColor(Color.RED);
|
||||
sr.rect(attackbox.x, attackbox.y, attackbox.width, attackbox.height);
|
||||
}
|
||||
float arrowX = isFacingRight ? hitbox.x + hitbox.width + 5 : hitbox.x - 15;
|
||||
sr.setColor(Color.YELLOW);
|
||||
sr.line(arrowX, hitbox.y + hitbox.height * 0.7f, arrowX + (isFacingRight ? 10 : -10),
|
||||
hitbox.y + hitbox.height * 0.7f);
|
||||
}
|
||||
|
||||
public void handleInput(int keycode, boolean isPressed, float duration) {
|
||||
if (isPressed) {
|
||||
if (keycode == Input.Keys.LEFT || keycode == Input.Keys.A) {
|
||||
move(-1, Gdx.graphics.getDeltaTime());
|
||||
} else if (keycode == Input.Keys.RIGHT || keycode == Input.Keys.D) {
|
||||
move(1, Gdx.graphics.getDeltaTime());
|
||||
}
|
||||
if (keycode == Input.Keys.SPACE || keycode == Input.Keys.UP || keycode == Input.Keys.W) {
|
||||
jump();
|
||||
}
|
||||
if (!isAttacking && !defending && globalAttackCDTimer <= 0f) {
|
||||
if (keycode == Input.Keys.Z || keycode == Input.Keys.J) {
|
||||
attack("light");
|
||||
NetworkManager.getInstance().sendAttack("light", getFacingDir());
|
||||
} else if (keycode == Input.Keys.X || keycode == Input.Keys.K) {
|
||||
attack("heavy");
|
||||
NetworkManager.getInstance().sendAttack("heavy", getFacingDir());
|
||||
} else if (keycode == Input.Keys.SHIFT_LEFT || keycode == Input.Keys.SHIFT_RIGHT) {
|
||||
attack("special");
|
||||
NetworkManager.getInstance().sendAttack("special", getFacingDir());
|
||||
}
|
||||
}
|
||||
// 防御键(C / 左CTRL)
|
||||
if (keycode == Input.Keys.C || keycode == Input.Keys.CONTROL_LEFT) {
|
||||
if (!isAttacking && isAlive()) {
|
||||
defending = true;
|
||||
changeAction(Action.DEFEND);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ((keycode == Input.Keys.LEFT || keycode == Input.Keys.RIGHT || keycode == Input.Keys.A
|
||||
|| keycode == Input.Keys.D) && getCurrentAction() == Action.MOVE) {
|
||||
changeAction(Action.IDLE);
|
||||
}
|
||||
if (keycode == Input.Keys.C || keycode == Input.Keys.CONTROL_LEFT) {
|
||||
defending = false;
|
||||
if (currentAction == Action.DEFEND)
|
||||
changeAction(Action.IDLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Action getCurrentAction() {
|
||||
return currentAction;
|
||||
}
|
||||
|
||||
public void changeAction(Action newAction) {
|
||||
this.currentAction = ActionStateGuard.transition(this, newAction);
|
||||
}
|
||||
|
||||
void directSetAction(Action a) {
|
||||
this.currentAction = a;
|
||||
}
|
||||
|
||||
public void jump() {
|
||||
if (isGrounded) {
|
||||
verticalSpeed = GameConstants.JUMP_SPEED;
|
||||
isGrounded = false;
|
||||
changeAction(Action.JUMP);
|
||||
}
|
||||
}
|
||||
|
||||
public void move(float x, float deltaTime) {
|
||||
// x = -1,0,1 方向输入
|
||||
float targetSign = x;
|
||||
float accel = GameConstants.MOVE_ACCEL;
|
||||
if (!isGrounded) {
|
||||
accel *= GameConstants.AIR_ACCEL_FACTOR; // 空中降低加速度便于区分地面/空中控制
|
||||
}
|
||||
if (targetSign != 0) {
|
||||
isFacingRight = targetSign > 0;
|
||||
// 向目标速度加速
|
||||
float targetVel = targetSign * speed;
|
||||
if (velX < targetVel) {
|
||||
velX = Math.min(targetVel, velX + accel * deltaTime);
|
||||
} else if (velX > targetVel) {
|
||||
velX = Math.max(targetVel, velX - accel * deltaTime);
|
||||
}
|
||||
} else {
|
||||
// 无输入:减速(朝 0 逼近)
|
||||
float decel = GameConstants.MOVE_DECEL * deltaTime;
|
||||
if (velX > 0) {
|
||||
velX = Math.max(0, velX - decel);
|
||||
} else if (velX < 0) {
|
||||
velX = Math.min(0, velX + decel);
|
||||
}
|
||||
}
|
||||
// 应用位移
|
||||
hitbox.x += velX * deltaTime;
|
||||
// 状态切换
|
||||
if (Math.abs(velX) > 5f && isGrounded && !isAttacking) {
|
||||
changeAction(Action.MOVE);
|
||||
} else if (isGrounded && !isAttacking && targetSign == 0 && Math.abs(velX) <= 5f) {
|
||||
velX = 0f; // 近乎停止直接归零
|
||||
changeAction(Action.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// 供远程同步:根据位置变化量更新朝向(不触发移动动作,只用于攻击盒朝向正确)
|
||||
public void updateFacingByDelta(float dx) {
|
||||
if (dx > 0) {
|
||||
isFacingRight = true;
|
||||
} else if (dx < 0) {
|
||||
isFacingRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isFacingRight() {
|
||||
return isFacingRight;
|
||||
}
|
||||
|
||||
public void setFacingRight(boolean facingRight) {
|
||||
this.isFacingRight = facingRight;
|
||||
}
|
||||
|
||||
public String getFacingDir() {
|
||||
return isFacingRight ? "R" : "L";
|
||||
}
|
||||
|
||||
private void updateAttackbox(String attackType) {
|
||||
float baseOffsetY = 20f;
|
||||
float width = 80f, height = 80f;
|
||||
float offsetX;
|
||||
float offsetY = baseOffsetY;
|
||||
// 先决定尺寸,再根据朝向计算 offset(不再用旧 attackbox.width 避免漂移)
|
||||
switch (attackType) {
|
||||
case "heavy":
|
||||
width = 100f;
|
||||
height = 100f;
|
||||
offsetY = 40f;
|
||||
offsetX = isFacingRight ? hitbox.width : -width; // 重击靠近身体或覆盖前方
|
||||
break;
|
||||
case "special":
|
||||
width = 120f;
|
||||
height = 60f;
|
||||
offsetY = 50f;
|
||||
offsetX = isFacingRight ? hitbox.width + 20f : -width - 20f;
|
||||
break;
|
||||
case "light":
|
||||
default:
|
||||
// 轻击稍微往前,不再参考旧 attackbox.width
|
||||
offsetX = isFacingRight ? hitbox.width - 10f : -80f + 10f;
|
||||
break;
|
||||
}
|
||||
attackbox.setSize(width, height);
|
||||
attackbox.setPosition(hitbox.x + offsetX, hitbox.y + offsetY);
|
||||
}
|
||||
|
||||
public void attack(String attackType) {
|
||||
isAttacking = true;
|
||||
attackPhase = AttackPhase.STARTUP;
|
||||
switch (attackType) {
|
||||
case "heavy":
|
||||
curStartup = HEAVY_STARTUP;
|
||||
curActive = HEAVY_ACTIVE;
|
||||
curRecovery = HEAVY_RECOVERY;
|
||||
break;
|
||||
case "special":
|
||||
curStartup = SPECIAL_STARTUP;
|
||||
curActive = SPECIAL_ACTIVE;
|
||||
curRecovery = SPECIAL_RECOVERY;
|
||||
break;
|
||||
case "light":
|
||||
default:
|
||||
curStartup = LIGHT_STARTUP;
|
||||
curActive = LIGHT_ACTIVE;
|
||||
curRecovery = LIGHT_RECOVERY;
|
||||
break;
|
||||
}
|
||||
attackTimer = curStartup;
|
||||
attackJustStarted = true;
|
||||
changeAction(Action.ATTACK);
|
||||
updateAttackbox(attackType);
|
||||
lastAttackType = attackType;
|
||||
attackSequence++; // 本地每次攻击序号自增
|
||||
lastDamageAppliedSeq = -1; // 重置伤害标记
|
||||
}
|
||||
|
||||
public void takeHit(int damage) {
|
||||
takeHit(damage, 0);
|
||||
}
|
||||
|
||||
public void takeHit(int damage, int dirSign) {
|
||||
if (invulnerableTimer > 0 || health <= 0)
|
||||
return;
|
||||
int finalDamage = damage;
|
||||
boolean wasDefending = defending && currentAction == Action.DEFEND;
|
||||
if (wasDefending) {
|
||||
finalDamage = Math.max(1, Math.round(damage * DEFEND_DAMAGE_FACTOR));
|
||||
}
|
||||
health = Math.max(0, health - finalDamage);
|
||||
if (!(wasDefending && health > 0)) {
|
||||
changeAction(health > 0 ? Action.HIT : Action.DEAD);
|
||||
}
|
||||
invulnerableTimer = INVULNERABLE_DURATION;
|
||||
float baseKb = 600f;
|
||||
if (wasDefending)
|
||||
baseKb *= DEFEND_KNOCKBACK_FACTOR;
|
||||
if (dirSign == 0) {
|
||||
knockbackX = isFacingRight ? -baseKb : baseKb;
|
||||
} else {
|
||||
knockbackX = dirSign * baseKb;
|
||||
}
|
||||
knockbackTimer = KNOCKBACK_DURATION;
|
||||
if (health == 0)
|
||||
deathFadeTimer = DEATH_FADE_DURATION;
|
||||
}
|
||||
|
||||
public boolean isAlive() {
|
||||
return health > 0;
|
||||
}
|
||||
|
||||
public boolean isAttacking() {
|
||||
return isAttacking;
|
||||
}
|
||||
|
||||
public boolean isInvulnerable() {
|
||||
return invulnerableTimer > 0;
|
||||
}
|
||||
|
||||
public Rectangle getHitbox() {
|
||||
return hitbox;
|
||||
}
|
||||
|
||||
public Rectangle getAttackbox() {
|
||||
return attackbox;
|
||||
}
|
||||
|
||||
public int getHealth() {
|
||||
return health;
|
||||
}
|
||||
|
||||
public int getMaxHealth() {
|
||||
return MAX_HEALTH;
|
||||
}
|
||||
|
||||
public int getAttackSequence() {
|
||||
return attackSequence;
|
||||
}
|
||||
|
||||
public String getLastAttackType() {
|
||||
return lastAttackType;
|
||||
}
|
||||
|
||||
public int getLastDamageAppliedSeq() {
|
||||
return lastDamageAppliedSeq;
|
||||
}
|
||||
|
||||
public void setLastDamageAppliedSeq(int seq) {
|
||||
this.lastDamageAppliedSeq = seq;
|
||||
}
|
||||
|
||||
public boolean canDealDamage() {
|
||||
// 只有 ACTIVE 阶段且本序号未造成过伤害才允许
|
||||
return isAttacking && attackPhase == AttackPhase.ACTIVE && attackSequence != lastDamageAppliedSeq;
|
||||
}
|
||||
|
||||
public void markDamageApplied() {
|
||||
lastDamageAppliedSeq = attackSequence;
|
||||
}
|
||||
|
||||
// 根据攻击类型返回伤害数值
|
||||
public int getDamageForAttack(String type) {
|
||||
switch (type) {
|
||||
case "heavy":
|
||||
return 20;
|
||||
case "special":
|
||||
return 30;
|
||||
case "light":
|
||||
default:
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setPosition(float x, float y) {
|
||||
hitbox.setPosition(x, y);
|
||||
}
|
||||
|
||||
/** 若当前 Y 低于地面则贴到地面。 */
|
||||
public void alignToGround() {
|
||||
if (hitbox.y < GameConstants.GROUND_Y) {
|
||||
hitbox.y = GameConstants.GROUND_Y;
|
||||
}
|
||||
}
|
||||
|
||||
public float getAttackTimer() {
|
||||
return attackTimer;
|
||||
}
|
||||
|
||||
public boolean isInActivePhase() {
|
||||
return isAttacking && attackPhase == AttackPhase.ACTIVE;
|
||||
}
|
||||
|
||||
public boolean isInStartupPhase() {
|
||||
return isAttacking && attackPhase == AttackPhase.STARTUP;
|
||||
}
|
||||
|
||||
public boolean isInRecoveryPhase() {
|
||||
return isAttacking && attackPhase == AttackPhase.RECOVERY;
|
||||
}
|
||||
|
||||
// 重生时重置状态
|
||||
public void resetForRespawn() {
|
||||
health = MAX_HEALTH;
|
||||
isAttacking = false;
|
||||
attackTimer = 0f;
|
||||
attackJustStarted = false;
|
||||
changeAction(Action.IDLE);
|
||||
invulnerableTimer = 0f;
|
||||
knockbackTimer = 0f;
|
||||
knockbackX = 0f;
|
||||
deathFadeTimer = 0f; // 重置淡出
|
||||
defending = false;
|
||||
velX = 0f;
|
||||
globalAttackCDTimer = 0f;
|
||||
}
|
||||
|
||||
/** 获取当前用于渲染的死亡淡出透明度(1=不透明,0=已完全淡出)。 */
|
||||
public float getRenderAlpha() {
|
||||
if (health > 0)
|
||||
return 1f;
|
||||
return deathFadeTimer / DEATH_FADE_DURATION; // 线性
|
||||
}
|
||||
|
||||
public boolean isDefending() {
|
||||
return defending && currentAction == Action.DEFEND;
|
||||
}
|
||||
}
|
||||
158
src/main/java/uno/mloluyu/characters/ai/SimpleFighterAI.java
Normal file
@@ -0,0 +1,158 @@
|
||||
package uno.mloluyu.characters.ai;
|
||||
|
||||
import com.badlogic.gdx.ai.fsm.DefaultStateMachine;
|
||||
import com.badlogic.gdx.ai.fsm.State;
|
||||
import com.badlogic.gdx.ai.fsm.StateMachine;
|
||||
import com.badlogic.gdx.ai.msg.Telegram;
|
||||
import com.badlogic.gdx.math.MathUtils;
|
||||
import uno.mloluyu.characters.SimpleFighter;
|
||||
|
||||
/**
|
||||
* 一个非常简化的 AI:
|
||||
* - 距离目标远: 向目标靠近
|
||||
* - 距离合适: 停下并有概率攻击
|
||||
* - 偶尔随机跳一下
|
||||
*/
|
||||
public class SimpleFighterAI {
|
||||
private final SimpleFighter self;
|
||||
private SimpleFighter target; // 追踪的目标(本地玩家)
|
||||
private final StateMachine<SimpleFighterAI, AIState> fsm;
|
||||
|
||||
// 参数(后续可提取到 GameConstants 或配置文件)
|
||||
private float attackCooldown = 0f;
|
||||
private float jumpCooldown = 0f;
|
||||
private static final float ATTACK_COOLDOWN_MIN = 0.6f;
|
||||
private static final float ATTACK_COOLDOWN_MAX = 1.2f;
|
||||
private static final float JUMP_COOLDOWN_MIN = 2.5f;
|
||||
private static final float JUMP_COOLDOWN_MAX = 4.5f;
|
||||
private static final float DESIRED_DISTANCE = 140f; // 停下的理想距离
|
||||
private static final float ATTACK_RANGE = 130f; // 触发攻击的距离
|
||||
|
||||
public SimpleFighterAI(SimpleFighter self, SimpleFighter target) {
|
||||
this.self = self;
|
||||
this.target = target;
|
||||
this.fsm = new DefaultStateMachine<>(this, AIState.IDLE);
|
||||
resetAttackCd();
|
||||
resetJumpCd();
|
||||
}
|
||||
|
||||
private void resetAttackCd() {
|
||||
attackCooldown = MathUtils.random(ATTACK_COOLDOWN_MIN, ATTACK_COOLDOWN_MAX);
|
||||
}
|
||||
|
||||
private void resetJumpCd() {
|
||||
jumpCooldown = MathUtils.random(JUMP_COOLDOWN_MIN, JUMP_COOLDOWN_MAX);
|
||||
}
|
||||
|
||||
public void update(float delta) {
|
||||
attackCooldown -= delta;
|
||||
jumpCooldown -= delta;
|
||||
fsm.update();
|
||||
}
|
||||
|
||||
public void setTarget(SimpleFighter target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
private float horizontalDistance() {
|
||||
if (target == null)
|
||||
return Float.MAX_VALUE;
|
||||
return target.getHitbox().x - self.getHitbox().x;
|
||||
}
|
||||
|
||||
private float absDistance() {
|
||||
return Math.abs(horizontalDistance());
|
||||
}
|
||||
|
||||
enum AIState implements State<SimpleFighterAI> {
|
||||
IDLE {
|
||||
@Override
|
||||
public void enter(SimpleFighterAI ai) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(SimpleFighterAI ai) {
|
||||
if (ai.target == null || !ai.target.isAlive())
|
||||
return;
|
||||
float dist = ai.absDistance();
|
||||
if (dist > DESIRED_DISTANCE * 1.3f) {
|
||||
ai.fsm.changeState(MOVE);
|
||||
return;
|
||||
}
|
||||
// 在理想距离范围内尝试攻击
|
||||
ai.tryAttack();
|
||||
ai.tryJump();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exit(SimpleFighterAI ai) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMessage(SimpleFighterAI entity, Telegram telegram) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
MOVE {
|
||||
@Override
|
||||
public void enter(SimpleFighterAI ai) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(SimpleFighterAI ai) {
|
||||
if (ai.target == null || !ai.target.isAlive()) {
|
||||
ai.fsm.changeState(IDLE);
|
||||
return;
|
||||
}
|
||||
float distSigned = ai.horizontalDistance();
|
||||
float distAbs = Math.abs(distSigned);
|
||||
if (distAbs < DESIRED_DISTANCE) {
|
||||
ai.fsm.changeState(IDLE);
|
||||
return;
|
||||
}
|
||||
float dir = Math.signum(distSigned);
|
||||
ai.self.move(dir, com.badlogic.gdx.Gdx.graphics.getDeltaTime());
|
||||
// 攻击机会(接近过程中如果已经够近)
|
||||
if (distAbs < ATTACK_RANGE * 0.9f) {
|
||||
ai.tryAttack();
|
||||
}
|
||||
ai.tryJump();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exit(SimpleFighterAI ai) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMessage(SimpleFighterAI entity, Telegram telegram) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void tryAttack() {
|
||||
if (attackCooldown <= 0f && self.isAlive() && !self.isAttacking()) {
|
||||
float dist = absDistance();
|
||||
if (dist < ATTACK_RANGE) {
|
||||
// 轻重攻击随机
|
||||
String atkType;
|
||||
float r = MathUtils.random();
|
||||
if (r < 0.7f)
|
||||
atkType = "light";
|
||||
else if (r < 0.9f)
|
||||
atkType = "heavy";
|
||||
else
|
||||
atkType = "special";
|
||||
self.attack(atkType);
|
||||
resetAttackCd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void tryJump() {
|
||||
if (jumpCooldown <= 0f && self.isAlive() && MathUtils.random() < 0.15f) {
|
||||
self.jump();
|
||||
resetJumpCd();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package uno.mloluyu.characters.effects;
|
||||
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
|
||||
/** 简单攻击特效:在攻击盒区域闪现并淡出。 */
|
||||
public class AttackEffect {
|
||||
private final Rectangle area = new Rectangle();
|
||||
private float life; // 剩余寿命
|
||||
private final float totalLife;
|
||||
private final Color color = new Color();
|
||||
|
||||
public AttackEffect(Rectangle src, float duration, Color base) {
|
||||
this.area.set(src);
|
||||
this.life = duration;
|
||||
this.totalLife = duration;
|
||||
this.color.set(base);
|
||||
}
|
||||
|
||||
public boolean isAlive() {
|
||||
return life > 0f;
|
||||
}
|
||||
|
||||
public void update(float delta) {
|
||||
life -= delta;
|
||||
}
|
||||
|
||||
public void render(ShapeRenderer sr) {
|
||||
if (!isAlive())
|
||||
return;
|
||||
float alpha = life / totalLife; // 线性淡出
|
||||
sr.setColor(color.r, color.g, color.b, alpha * color.a);
|
||||
sr.rect(area.x, area.y, area.width, area.height);
|
||||
}
|
||||
|
||||
public void renderOutline(ShapeRenderer sr) {
|
||||
if (!isAlive())
|
||||
return;
|
||||
float alpha = life / totalLife;
|
||||
sr.setColor(color.r, color.g, color.b, alpha * 0.6f);
|
||||
sr.rect(area.x, area.y, area.width, area.height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package uno.mloluyu.characters.effects;
|
||||
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.math.MathUtils;
|
||||
|
||||
/** 单个命中粒子:简单方块/短线喷射,带速度、重力与淡出。 */
|
||||
public class HitParticle {
|
||||
private float x, y;
|
||||
private float vx, vy;
|
||||
private float life; // 剩余寿命
|
||||
private final float totalLife;
|
||||
private final float size;
|
||||
private final Color color = new Color();
|
||||
private static final float GRAVITY = 900f; // 轻微下坠
|
||||
|
||||
public HitParticle(float x, float y, Color base, float speedMin, float speedMax, float lifeMin, float lifeMax,
|
||||
float sizeMin, float sizeMax) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
float ang = MathUtils.random(15f, 165f) * MathUtils.degreesToRadians; // 向上扇形
|
||||
float spd = MathUtils.random(speedMin, speedMax);
|
||||
this.vx = MathUtils.cos(ang) * spd;
|
||||
this.vy = MathUtils.sin(ang) * spd;
|
||||
this.life = MathUtils.random(lifeMin, lifeMax);
|
||||
this.totalLife = life;
|
||||
this.size = MathUtils.random(sizeMin, sizeMax);
|
||||
// 基础色随机稍许扰动
|
||||
float tint = MathUtils.random(0.85f, 1.05f);
|
||||
this.color.set(base.r * tint, base.g * tint, base.b * tint, 1f);
|
||||
}
|
||||
|
||||
public boolean isAlive() {
|
||||
return life > 0f;
|
||||
}
|
||||
|
||||
public void update(float dt) {
|
||||
if (!isAlive())
|
||||
return;
|
||||
life -= dt;
|
||||
// 运动积分
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
vy -= GRAVITY * dt * 0.35f; // 轻微重力
|
||||
}
|
||||
|
||||
public void render(ShapeRenderer sr) {
|
||||
if (!isAlive())
|
||||
return;
|
||||
float a = (life / totalLife); // 线性淡出
|
||||
sr.setColor(color.r, color.g, color.b, a);
|
||||
sr.rect(x - size / 2f, y - size / 2f, size, size);
|
||||
}
|
||||
}
|
||||
48
src/main/java/uno/mloluyu/characters/effects/RadialRing.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package uno.mloluyu.characters.effects;
|
||||
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
|
||||
/**
|
||||
* 攻击进入 ACTIVE 瞬间生成的环形扩散效果:半径扩大+透明衰减。
|
||||
*/
|
||||
public class RadialRing {
|
||||
private float x, y;
|
||||
private float radius;
|
||||
private float maxRadius;
|
||||
private float life;
|
||||
private float maxLife;
|
||||
private Color color;
|
||||
|
||||
public RadialRing(float x, float y, float startRadius, float maxRadius, float life, Color color) {
|
||||
this.x = x; this.y = y; this.radius = startRadius; this.maxRadius = maxRadius; this.life = life; this.maxLife = life; this.color = new Color(color);
|
||||
}
|
||||
|
||||
public void update(float dt) {
|
||||
life -= dt;
|
||||
float t = 1f - life / maxLife; // 0 -> 1
|
||||
radius = (startRadius() + (maxRadius - startRadius()) * t);
|
||||
}
|
||||
|
||||
private float startRadius() { return 6f; }
|
||||
|
||||
public boolean isAlive() { return life > 0f; }
|
||||
|
||||
public void render(ShapeRenderer sr) {
|
||||
float alpha = life / maxLife;
|
||||
sr.setColor(color.r, color.g, color.b, alpha * color.a);
|
||||
// 使用多段线模拟圆环(填充模式下画薄圆)
|
||||
int segments = 26;
|
||||
float lineWidth = 2f;
|
||||
float step = (float)(Math.PI * 2 / segments);
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float a1 = i * step;
|
||||
float a2 = (i + 1) * step;
|
||||
float x1 = x + (float)Math.cos(a1) * radius;
|
||||
float y1 = y + (float)Math.sin(a1) * radius;
|
||||
float x2 = x + (float)Math.cos(a2) * radius;
|
||||
float y2 = y + (float)Math.sin(a2) * radius;
|
||||
sr.rectLine(x1, y1, x2, y2, lineWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package uno.mloluyu.characters.effects;
|
||||
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
|
||||
public class SparkParticle {
|
||||
private float x, y;
|
||||
private float vx, vy;
|
||||
private float life;
|
||||
private float maxLife;
|
||||
private float length;
|
||||
private Color color;
|
||||
|
||||
public SparkParticle(float x, float y, float speed, float angle, float life, float length, Color base) {
|
||||
this.x = x; this.y = y;
|
||||
this.vx = (float)Math.cos(angle) * speed;
|
||||
this.vy = (float)Math.sin(angle) * speed;
|
||||
this.life = life; this.maxLife = life; this.length = length; this.color = new Color(base);
|
||||
}
|
||||
|
||||
public void update(float dt) {
|
||||
life -= dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
vy -= 900f * dt; // 轻微重力
|
||||
}
|
||||
|
||||
public boolean isAlive() { return life > 0f; }
|
||||
|
||||
public void render(ShapeRenderer sr) {
|
||||
float t = life / maxLife;
|
||||
float a = t; // 线性淡出
|
||||
sr.setColor(color.r, color.g, color.b, a * color.a);
|
||||
float nx = x - vx * 0.015f; // 逆向一点形成拖尾
|
||||
float ny = y - vy * 0.015f;
|
||||
sr.rectLine(x, y, nx, ny, Math.max(1.2f, length * t));
|
||||
}
|
||||
}
|
||||
19
src/main/java/uno/mloluyu/characters/effects/Untitled-1.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form>
|
||||
<label for="username">用户名:</label>
|
||||
<input type="text" id="username" name="username"><br><br>
|
||||
<input type="checkbox" id="remember">
|
||||
<label for="remember">记住用户名</label><br><br>
|
||||
<input type="button" value="提交" onclick="submitForm()">
|
||||
</form>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
src/main/java/uno/mloluyu/desktop/BaseScreen.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
|
||||
/** 所有 Screen 的基础类,集中生命周期钩子扩展。 */
|
||||
public abstract class BaseScreen extends ScreenAdapter {
|
||||
protected final MainGame game;
|
||||
|
||||
protected BaseScreen(MainGame game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
/** 提供可选的资源预加载完成回调 */
|
||||
public void onAssetsReady() {
|
||||
}
|
||||
}
|
||||
232
src/main/java/uno/mloluyu/desktop/CharacterSelectScreen.java
Normal file
@@ -0,0 +1,232 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import com.badlogic.gdx.Input;
|
||||
import com.badlogic.gdx.InputProcessor;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import uno.mloluyu.characters.AdvancedFighter;
|
||||
import uno.mloluyu.network.NetworkManager;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.g2d.BitmapFont;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
|
||||
import uno.mloluyu.characters.SimpleFighter;
|
||||
import uno.mloluyu.util.ClearScreen;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static uno.mloluyu.util.Font.loadChineseFont;
|
||||
|
||||
public class CharacterSelectScreen extends ScreenAdapter implements InputProcessor {
|
||||
private boolean multiplayerMode = false; // 默认为单人模式
|
||||
|
||||
private final MainGame game;
|
||||
private SpriteBatch batch;
|
||||
private BitmapFont font;
|
||||
private ShapeRenderer shapeRenderer;
|
||||
private SimpleFighter selectedFighter1;
|
||||
private SimpleFighter selectedFighter2;
|
||||
|
||||
private final List<Texture> bgs = Arrays.asList(
|
||||
new Texture(Gdx.files.internal("selectpage/10b_back_blue2p.png")),
|
||||
// new Texture(Gdx.files.internal("selectpage/back_door.png")),
|
||||
new Texture(Gdx.files.internal("selectpage/11b_back_red1p.png")));
|
||||
private final List<Texture> charsTexts = Arrays.asList(
|
||||
new Texture(Gdx.files.internal("selectpage/character_03.png")),
|
||||
new Texture(Gdx.files.internal("selectpage/character_00.png")));
|
||||
private final List<String> characters = Arrays.asList("Alice", "Reimu", "暂定");
|
||||
private Texture profile1p = charsTexts.get(0);
|
||||
private Texture profile2p = charsTexts.get(1);
|
||||
private Texture selectText;
|
||||
|
||||
private static int selectedIndex = 0;
|
||||
private static boolean is1P = true;
|
||||
|
||||
// 下面这些按钮常量原本用于文本/按钮绘制,当前 UI 逻辑已注释。
|
||||
// 如果后续恢复 renderTexts() 可重新启用;为减少无用警告暂时注释。
|
||||
// private static final int BUTTON_WIDTH = 300;
|
||||
// private static final int BUTTON_HEIGHT = 80;
|
||||
// private static final int BUTTON_X = 800;
|
||||
// private static final int CONFIRM_Y = 200;
|
||||
// private static final int BACK_Y = 100;
|
||||
|
||||
public CharacterSelectScreen(MainGame game) {
|
||||
this.game = game;
|
||||
Gdx.input.setInputProcessor(this);
|
||||
}
|
||||
|
||||
public void setMultiplayerMode(boolean multiplayerMode) {
|
||||
this.multiplayerMode = multiplayerMode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
batch = new SpriteBatch();
|
||||
shapeRenderer = new ShapeRenderer();
|
||||
font = loadChineseFont();
|
||||
font.setColor(Color.WHITE);
|
||||
font.getData().setScale(2f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
// 清屏:使用工具静态方法,避免误用私有构造器
|
||||
ClearScreen.clear();
|
||||
|
||||
int mouseX = Gdx.input.getX();
|
||||
int mouseY = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
renderBackground();
|
||||
renderCharacters(multiplayerMode);
|
||||
// renderTexts();
|
||||
|
||||
handleInput(mouseX, mouseY);
|
||||
if (multiplayerMode) {
|
||||
// 显示联机模式提示
|
||||
batch.begin();
|
||||
font.draw(batch, "联机模式 - 等待其他玩家连接...", 100, 100);
|
||||
batch.end();
|
||||
} else {
|
||||
// 显示单人模式提示
|
||||
batch.begin();
|
||||
font.draw(batch, "单人模式", 100, 100);
|
||||
batch.end();
|
||||
}
|
||||
}
|
||||
|
||||
private void renderBackground() {
|
||||
batch.begin();
|
||||
for (int i = 0; i < bgs.size(); i++) {
|
||||
batch.draw(bgs.get(i), 0, 528 * i, 1920, 528);
|
||||
}
|
||||
batch.end();
|
||||
}
|
||||
|
||||
private void renderCharacters(boolean multiplayerMode) {
|
||||
batch.begin();
|
||||
batch.draw(profile1p, 0, 0, profile1p.getWidth() * 3, profile1p.getHeight() * 3);
|
||||
batch.draw(profile2p, 0, 528, profile2p.getWidth() * 3, profile2p.getHeight() * 3);
|
||||
batch.end();
|
||||
}
|
||||
//
|
||||
// private void renderTexts() {
|
||||
// batch.begin();
|
||||
// font.draw(batch, "选择你的角色", 200, 650);
|
||||
// for (int i = 0; i < characters.size(); i++) {
|
||||
// int x = 200 + 30;
|
||||
// int y = 500 - i * 120 + 50;
|
||||
// font.draw(batch, characters.get(i), x, y);
|
||||
//
|
||||
// }
|
||||
// if (selectedIndex != -1) {
|
||||
// font.draw(batch, "已选择: " + characters.get(selectedIndex), 200, 100);
|
||||
// }
|
||||
// drawButtonText(CONFIRM_Y, "确认");
|
||||
// drawButtonText(BACK_Y, "返回");
|
||||
// batch.end();
|
||||
// }
|
||||
//
|
||||
|
||||
private void handleInput(int mouseX, int mouseY) {
|
||||
// 单人模式:只要1P选择了角色就进入游戏
|
||||
if (!multiplayerMode && selectedFighter1 != null) {
|
||||
game.setScreen(new GameScreen(game, selectedFighter1));
|
||||
return;
|
||||
}
|
||||
// 联机模式:等待双方选择(当前逻辑仍采用2人都选才进入)
|
||||
if (multiplayerMode && selectedFighter1 != null && selectedFighter2 != null) {
|
||||
if (NetworkManager.getInstance().getLocalPlayerId() == null) {
|
||||
String playerId = UUID.randomUUID().toString();
|
||||
NetworkManager.getInstance().setLocalPlayerId(playerId);
|
||||
Gdx.app.log("Network", "设置玩家ID: " + playerId);
|
||||
}
|
||||
NetworkManager.getInstance().sendCharacterSelection(selectedFighter1.getName());
|
||||
game.setScreen(new GameScreen(game, selectedFighter1));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
batch.dispose();
|
||||
font.dispose();
|
||||
shapeRenderer.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyDown(int i) {
|
||||
boolean value = false;
|
||||
if (!multiplayerMode) {
|
||||
if (i == Input.Keys.LEFT || i == Input.Keys.RIGHT) {
|
||||
selectText = charsTexts.get(selectedIndex);
|
||||
selectedIndex = (selectedIndex + 1) % charsTexts.size();
|
||||
if (is1P) {
|
||||
profile1p = selectText;
|
||||
} else {
|
||||
profile2p = selectText;
|
||||
}
|
||||
}
|
||||
value = true;
|
||||
} else {
|
||||
// 占坑说是
|
||||
}
|
||||
if (i == Input.Keys.Z) {
|
||||
if (is1P) {
|
||||
selectedFighter1 = new AdvancedFighter(characters.get(selectedIndex));
|
||||
} else {
|
||||
selectedFighter2 = new AdvancedFighter(characters.get(selectedIndex));
|
||||
}
|
||||
is1P = false;
|
||||
selectedIndex = 1;
|
||||
value = true;
|
||||
}
|
||||
if (i == Input.Keys.ESCAPE) {
|
||||
Gdx.app.log("Character", "返回主菜单");
|
||||
game.setScreen(new MainMenuScreen(game));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyUp(int i) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyTyped(char c) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchDown(int i, int i1, int i2, int i3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchUp(int i, int i1, int i2, int i3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchCancelled(int i, int i1, int i2, int i3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchDragged(int i, int i1, int i2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseMoved(int i, int i1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean scrolled(float v, float v1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
27
src/main/java/uno/mloluyu/desktop/DesktopLauncher.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
|
||||
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
|
||||
|
||||
// 日志功能已移除
|
||||
|
||||
/**
|
||||
* Desktop 平台启动器
|
||||
*/
|
||||
public class DesktopLauncher {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
// 若需要异常抓取,可在此处添加简单的 Thread.setDefaultUncaughtExceptionHandler
|
||||
|
||||
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
|
||||
|
||||
float scale = 1.0F;
|
||||
config.width = (int) (1920 * scale); // 窗口宽度
|
||||
config.height = (int) (1080 * scale); // 窗口高度
|
||||
|
||||
config.resizable = false; // 窗口设置为大小不可改变
|
||||
|
||||
new LwjglApplication(new MainGame(), config);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.ApplicationListener;
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.GL20;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
|
||||
public class GameCore implements ApplicationListener {
|
||||
private SpriteBatch batch;
|
||||
private Texture img;
|
||||
|
||||
@Override
|
||||
public void create() {
|
||||
batch = new SpriteBatch();
|
||||
img = new Texture(Gdx.files.internal("badlogic.jpg"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render() {
|
||||
Gdx.gl.glClearColor(0.15f, 0.15f, 0.2f, 1);
|
||||
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
|
||||
|
||||
batch.begin();
|
||||
batch.draw(img, 0, 0);
|
||||
batch.end();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
batch.dispose();
|
||||
img.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resize(int width, int height) {}
|
||||
@Override
|
||||
public void pause() {}
|
||||
@Override
|
||||
public void resume() {}
|
||||
}
|
||||
889
src/main/java/uno/mloluyu/desktop/GameScreen.java
Normal file
@@ -0,0 +1,889 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.OrthographicCamera;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import com.badlogic.gdx.graphics.GL20;
|
||||
import uno.mloluyu.characters.Action;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
import com.badlogic.gdx.math.Vector3;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import uno.mloluyu.characters.SimpleFighter;
|
||||
import uno.mloluyu.characters.AdvancedFighter;
|
||||
import uno.mloluyu.network.NetworkManager;
|
||||
import uno.mloluyu.util.ClearScreen;
|
||||
import uno.mloluyu.perf.PerfMetrics;
|
||||
import uno.mloluyu.util.TimeStepLimiter;
|
||||
import uno.mloluyu.util.GameConstants;
|
||||
import uno.mloluyu.versatile.FighterController;
|
||||
import uno.mloluyu.characters.ai.SimpleFighterAI;
|
||||
import uno.mloluyu.characters.effects.AttackEffect;
|
||||
import uno.mloluyu.characters.effects.HitParticle;
|
||||
import uno.mloluyu.characters.effects.RadialRing;
|
||||
import uno.mloluyu.characters.effects.SparkParticle;
|
||||
|
||||
public class GameScreen extends ScreenAdapter {
|
||||
private final MainGame game; // 保存引用以便切换屏幕(重开/返回主界面)
|
||||
private final SimpleFighter player;
|
||||
private final FighterController controller;
|
||||
private final Map<String, SimpleFighter> otherPlayers = new HashMap<>();
|
||||
private final Map<String, Float> deathTimers = new HashMap<>(); // 记录远程/本地死亡计时
|
||||
private static final float RESPAWN_DELAY = 2f;
|
||||
|
||||
private SpriteBatch batch;
|
||||
private ShapeRenderer shapeRenderer;
|
||||
private OrthographicCamera camera;
|
||||
private Texture background;
|
||||
// 世界尺寸(基于背景原始尺寸 * 缩放)。如果需要可读取 Texture 宽高后动态设。
|
||||
private float worldWidth;
|
||||
private float worldHeight;
|
||||
// 背景整体缩放倍数:>1 表示背景比屏幕大,可只显示局部
|
||||
private static final float BACKGROUND_SCALE = 1.5f;
|
||||
// 摄像机竖直偏移(正值=镜头上移,让玩家更靠下;这里改为较小的正值防止看不到下方)
|
||||
private static final float CAMERA_Y_OFFSET = 60f;
|
||||
// 允许相机向下多看到的底部扩展(不被 clamp 过早挡住),解决放大 hitbox 下半部分出框
|
||||
private static final float CAMERA_BOTTOM_MARGIN = -30f;
|
||||
// 平滑跟随的垂直插值系数(独立控制 y,防止瞬间跳)
|
||||
private static final float CAMERA_LERP_ALPHA = 0.12f;
|
||||
// ========== 摄像机缩放配置 ==========
|
||||
// 是否使用动态缩放(多人时根据距离自动拉远 / 靠近)
|
||||
private static final boolean CAMERA_DYNAMIC_ZOOM = true;
|
||||
// (保留)固定缩放模式开关 & 数值;若需要强制固定视角可把 CAMERA_DYNAMIC_ZOOM 设 false
|
||||
private static final boolean CAMERA_USE_FIXED_ZOOM = false;
|
||||
private static final float CAMERA_FIXED_ZOOM = 0.80f;
|
||||
// 动态缩放参数:最小与最大(OrthographicCamera: <1 视角更近,>1 更远)
|
||||
private static final float CAMERA_MIN_ZOOM = 0.55f; // 角色很近时
|
||||
private static final float CAMERA_MAX_ZOOM = 1.25f; // 距离很远时
|
||||
// 达到最大缩放所对应的“玩家距离”基准(屏幕世界单位,按你的角色移动范围调)
|
||||
private static final float CAMERA_MAX_DISTANCE = 1800f;
|
||||
// 缩放插值速度(越大越快贴近目标)
|
||||
private static final float CAMERA_ZOOM_LERP = 0.10f;
|
||||
// 显示地面参考线
|
||||
private static final boolean SHOW_GROUND_LINE = true;
|
||||
// 半透明地面条带显示
|
||||
private static final boolean SHOW_GROUND_STRIP = true;
|
||||
private static final float GROUND_STRIP_HEIGHT = 14f; // 条带厚度
|
||||
private static final float GROUND_STRIP_ALPHA = 0.20f; // 透明度 (0~1)
|
||||
// AI 控制(单人模式自动生成一个 AI 对手)
|
||||
private SimpleFighterAI aiController;
|
||||
private SimpleFighter aiEnemy;
|
||||
private final java.util.List<AttackEffect> attackEffects = new java.util.ArrayList<>();
|
||||
private final java.util.List<HitParticle> hitParticles = new java.util.ArrayList<>();
|
||||
private final java.util.List<RadialRing> radialRings = new java.util.ArrayList<>();
|
||||
private final java.util.List<SparkParticle> sparkParticles = new java.util.ArrayList<>();
|
||||
|
||||
// ========== Game Over 相关 ==========
|
||||
private boolean gameOver = false;
|
||||
private String winnerName = "";
|
||||
private com.badlogic.gdx.graphics.g2d.BitmapFont uiFont;
|
||||
// 按钮区域(屏幕 UI 坐标)
|
||||
private final int GO_PANEL_W = 640;
|
||||
private final int GO_PANEL_H = 360;
|
||||
private final int GO_BUTTON_W = 280;
|
||||
private final int GO_BUTTON_H = 70;
|
||||
// 运行时计算位置(在渲染里根据当前窗口尺寸居中)
|
||||
|
||||
public GameScreen(MainGame game, SimpleFighter player) {
|
||||
this.game = game;
|
||||
this.player = player;
|
||||
this.controller = new FighterController(player);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
// 确保角色初始贴地(地面抬高后老存档/默认 0 需要调整)
|
||||
player.alignToGround();
|
||||
camera = new OrthographicCamera(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
|
||||
camera.position.set(player.getHitbox().x, player.getHitbox().y, 0); // 初始化摄像机位置
|
||||
// 初始化缩放:动态优先,否则固定
|
||||
if (CAMERA_DYNAMIC_ZOOM) {
|
||||
camera.zoom = CAMERA_MIN_ZOOM;
|
||||
} else if (CAMERA_USE_FIXED_ZOOM) {
|
||||
camera.zoom = CAMERA_FIXED_ZOOM;
|
||||
}
|
||||
camera.update();
|
||||
|
||||
batch = new SpriteBatch();
|
||||
shapeRenderer = new ShapeRenderer();
|
||||
Gdx.input.setInputProcessor(controller);
|
||||
// 背景图(与主菜单共用 bg.png),可后续扩展成多关卡背景
|
||||
background = new Texture(Gdx.files.internal("innerbg.png"));
|
||||
worldWidth = background.getWidth() * BACKGROUND_SCALE;
|
||||
worldHeight = background.getHeight() * BACKGROUND_SCALE;
|
||||
// 如果尚未联网,创建一个 AI 敌人
|
||||
if (!NetworkManager.getInstance().isConnected()) {
|
||||
aiEnemy = new SimpleFighter("AI");
|
||||
aiEnemy.setPosition(player.getHitbox().x + 400f, GameConstants.GROUND_Y);
|
||||
aiController = new SimpleFighterAI(aiEnemy, player);
|
||||
otherPlayers.put("AI_LOCAL", aiEnemy); // 重用现有渲染/碰撞逻辑
|
||||
}
|
||||
// 加载 UI 字体(含中文)
|
||||
try {
|
||||
uiFont = uno.mloluyu.util.Font.loadChineseFont();
|
||||
uiFont.getData().setScale(1.4f);
|
||||
} catch (Exception e) {
|
||||
// 忽略,fallback 在 Font 中已处理
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
delta = TimeStepLimiter.clamp(delta);
|
||||
ClearScreen.clear();
|
||||
PerfMetrics.frame(delta);
|
||||
// (原先背景在摄像机更新前绘制,会出现一帧“滞后”错位;已移到摄像机更新后)
|
||||
|
||||
// ================= 主更新(若未 Game Over)=================
|
||||
if (!gameOver) {
|
||||
player.update(delta);
|
||||
controller.update(delta);
|
||||
if (aiController != null) {
|
||||
aiController.update(delta);
|
||||
aiEnemy.update(delta);
|
||||
}
|
||||
// 自动朝向(本地玩家朝向最近的一个对手)
|
||||
autoFace(player, otherPlayers);
|
||||
// 远程/AI 对手也朝向本地玩家(不在攻击主动阶段时翻转,防止攻击盒跳变)
|
||||
for (SimpleFighter op : otherPlayers.values()) {
|
||||
autoFace(op, java.util.Collections.singletonMap("LOCAL", player));
|
||||
}
|
||||
}
|
||||
boolean connected = NetworkManager.getInstance().isConnected();
|
||||
if (!gameOver && connected) {
|
||||
NetworkManager.getInstance().sendPosition(player.getHitbox().x, player.getHitbox().y);
|
||||
Map<String, float[]> positions = NetworkManager.getInstance().getPlayerPositions();
|
||||
if (positions != null) {
|
||||
for (Map.Entry<String, float[]> entry : positions.entrySet()) {
|
||||
String playerId = entry.getKey();
|
||||
// 忽略本地玩家自己,仅渲染其他玩家
|
||||
if (playerId.equals(NetworkManager.getInstance().getLocalPlayerId())) {
|
||||
continue;
|
||||
}
|
||||
float[] pos = entry.getValue();
|
||||
if (pos == null) {
|
||||
continue;
|
||||
}
|
||||
// 根据网络上的角色选择信息来创建对应类型的远程角色实例,便于未来同步更多行为状态
|
||||
final String charName = NetworkManager.getInstance().getPlayerCharacters().get(entry.getKey());
|
||||
SimpleFighter remote = otherPlayers.computeIfAbsent(entry.getKey(), k -> {
|
||||
if (charName != null) {
|
||||
switch (charName) {
|
||||
case "Alice":
|
||||
case "Reimu":
|
||||
return new AdvancedFighter(charName);
|
||||
default:
|
||||
return new SimpleFighter("Remote-" + k);
|
||||
}
|
||||
}
|
||||
return new SimpleFighter("Remote-" + k);
|
||||
});
|
||||
// 根据位置变化更新朝向
|
||||
float oldX = remote.getHitbox().x;
|
||||
remote.setPosition(pos[0], pos[1]);
|
||||
float dx = remote.getHitbox().x - oldX;
|
||||
remote.updateFacingByDelta(dx);
|
||||
remote.update(delta);
|
||||
}
|
||||
// 处理远程攻击同步:触发远程角色的攻击动画
|
||||
Map<String, String> attacks = NetworkManager.getInstance().getPlayerAttacks();
|
||||
Map<String, String> dirs = NetworkManager.getInstance().getPlayerAttackDirs();
|
||||
for (Map.Entry<String, String> atk : attacks.entrySet()) {
|
||||
SimpleFighter remoteAtk = otherPlayers.get(atk.getKey());
|
||||
if (remoteAtk != null) {
|
||||
String dir = dirs.getOrDefault(atk.getKey(), "R");
|
||||
remoteAtk.setFacingRight("R".equals(dir));
|
||||
remoteAtk.attack(atk.getValue());
|
||||
}
|
||||
}
|
||||
attacks.clear();
|
||||
dirs.clear();
|
||||
}
|
||||
// 主机执行碰撞检测并广播伤害
|
||||
if (NetworkManager.getInstance().isHost()) {
|
||||
// 只处理本地玩家攻击命中其他人,以及(可扩展)其他远程之间不处理
|
||||
if (player.isAttacking()) {
|
||||
for (Map.Entry<String, SimpleFighter> e : otherPlayers.entrySet()) {
|
||||
SimpleFighter target = e.getValue();
|
||||
if (target.isAlive() && player.isAttacking() && player.canDealDamage()
|
||||
&& player.getAttackbox().overlaps(target.getHitbox())) {
|
||||
// 使用攻击类型伤害
|
||||
int dmg = player.getDamageForAttack(player.getLastAttackType());
|
||||
// 方向:本地玩家在左侧击中右侧 -> dirSign = 1 (目标被推向右);我们定义 dirSign = 攻击者面向 ? 1 : -1
|
||||
int dirSign = player.isFacingRight() ? 1 : -1;
|
||||
NetworkManager.getInstance().sendDamage(e.getKey(), dmg, dirSign);
|
||||
player.markDamageApplied();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 远程玩家攻击命中本地玩家
|
||||
for (Map.Entry<String, SimpleFighter> e : otherPlayers.entrySet()) {
|
||||
SimpleFighter attacker = e.getValue();
|
||||
if (attacker.isAttacking() && attacker.canDealDamage() && player.isAlive()
|
||||
&& attacker.getAttackbox().overlaps(player.getHitbox())) {
|
||||
int dmg = attacker.getDamageForAttack(attacker.getLastAttackType());
|
||||
int dirSign = attacker.isFacingRight() ? 1 : -1;
|
||||
NetworkManager.getInstance().sendDamage(NetworkManager.getInstance().getLocalPlayerId(), dmg,
|
||||
dirSign);
|
||||
attacker.markDamageApplied();
|
||||
}
|
||||
}
|
||||
// 主机处理死亡计时与重生广播
|
||||
// 本地玩家死亡
|
||||
if (!player.isAlive()) {
|
||||
deathTimers.merge(NetworkManager.getInstance().getLocalPlayerId(), Gdx.graphics.getDeltaTime(),
|
||||
Float::sum);
|
||||
}
|
||||
// 远程玩家死亡
|
||||
for (Map.Entry<String, SimpleFighter> e : otherPlayers.entrySet()) {
|
||||
if (!e.getValue().isAlive()) {
|
||||
deathTimers.merge(e.getKey(), Gdx.graphics.getDeltaTime(), Float::sum);
|
||||
}
|
||||
}
|
||||
// 检查需要重生的对象
|
||||
for (Map.Entry<String, Float> dt : new java.util.ArrayList<>(deathTimers.entrySet())) {
|
||||
if (dt.getValue() >= RESPAWN_DELAY) {
|
||||
String pid = dt.getKey();
|
||||
// 重生位置简单放原点附近随机
|
||||
float rx = (float) (Math.random() * 200 - 100);
|
||||
float ry = GameConstants.GROUND_Y;
|
||||
NetworkManager.getInstance().sendRespawn(pid, rx, ry);
|
||||
deathTimers.remove(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 应用收到的伤害事件
|
||||
Map<String, Integer> damageEvents = NetworkManager.getInstance().getDamageEvents();
|
||||
Map<String, Integer> damageDirs = NetworkManager.getInstance().getDamageDirs();
|
||||
if (!damageEvents.isEmpty()) {
|
||||
for (Map.Entry<String, Integer> dmg : damageEvents.entrySet()) {
|
||||
String targetId = dmg.getKey();
|
||||
int amount = dmg.getValue();
|
||||
int dirSign = damageDirs.getOrDefault(targetId, 0);
|
||||
if (targetId.equals(NetworkManager.getInstance().getLocalPlayerId())) {
|
||||
player.takeHit(amount, dirSign);
|
||||
} else {
|
||||
SimpleFighter remote = otherPlayers.get(targetId);
|
||||
if (remote != null) {
|
||||
remote.takeHit(amount, dirSign);
|
||||
}
|
||||
}
|
||||
}
|
||||
damageEvents.clear();
|
||||
damageDirs.clear();
|
||||
}
|
||||
// 处理重生事件
|
||||
Map<String, float[]> respawns = NetworkManager.getInstance().getRespawnEvents();
|
||||
if (!respawns.isEmpty()) {
|
||||
for (Map.Entry<String, float[]> r : respawns.entrySet()) {
|
||||
String pid = r.getKey();
|
||||
float[] p = r.getValue();
|
||||
if (pid.equals(NetworkManager.getInstance().getLocalPlayerId())) {
|
||||
player.setPosition(p[0], p[1]);
|
||||
player.resetForRespawn();
|
||||
} else {
|
||||
SimpleFighter remote = otherPlayers.get(pid);
|
||||
if (remote != null) {
|
||||
remote.setPosition(p[0], p[1]);
|
||||
remote.resetForRespawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
respawns.clear();
|
||||
}
|
||||
} else if (!gameOver) {
|
||||
// 离线模式:本地直接做攻击命中判定(本地玩家 -> AI,AI -> 本地)
|
||||
if (player.isAttacking() && player.canDealDamage()) {
|
||||
for (SimpleFighter op : otherPlayers.values()) {
|
||||
if (op.isAlive() && player.getAttackbox().overlaps(op.getHitbox())) {
|
||||
int dmg = player.getDamageForAttack(player.getLastAttackType());
|
||||
op.takeHit(dmg, player.isFacingRight() ? 1 : -1);
|
||||
spawnHitParticles(op, player.getLastAttackType(), player.isFacingRight());
|
||||
player.markDamageApplied();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (SimpleFighter op : otherPlayers.values()) {
|
||||
if (op.isAttacking() && op.canDealDamage() && player.isAlive()
|
||||
&& op.getAttackbox().overlaps(player.getHitbox())) {
|
||||
int dmg = op.getDamageForAttack(op.getLastAttackType());
|
||||
player.takeHit(dmg, op.isFacingRight() ? 1 : -1);
|
||||
spawnHitParticles(player, op.getLastAttackType(), op.isFacingRight());
|
||||
op.markDamageApplied();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gameOver) {
|
||||
// 监测攻击进入 ACTIVE 阶段的瞬间生成一次特效
|
||||
if (player.isInActivePhase() && player.canDealDamage()) {
|
||||
attackEffects.add(new AttackEffect(new Rectangle(player.getAttackbox()), 0.22f,
|
||||
new Color(0.3f, 0.6f, 1f, 0.55f)));
|
||||
spawnActivationFX(player, true);
|
||||
}
|
||||
for (SimpleFighter remote : otherPlayers.values()) {
|
||||
if (remote.isInActivePhase() && remote.canDealDamage()) {
|
||||
attackEffects.add(new AttackEffect(new Rectangle(remote.getAttackbox()), 0.22f,
|
||||
new Color(1f, 0.6f, 0.2f, 0.50f)));
|
||||
spawnActivationFX(remote, false);
|
||||
}
|
||||
}
|
||||
// 更新矩形攻击特效寿命
|
||||
if (!attackEffects.isEmpty()) {
|
||||
for (int i = attackEffects.size() - 1; i >= 0; i--) {
|
||||
AttackEffect ef = attackEffects.get(i);
|
||||
ef.update(delta);
|
||||
if (!ef.isAlive())
|
||||
attackEffects.remove(i);
|
||||
}
|
||||
}
|
||||
// 更新粒子
|
||||
if (!hitParticles.isEmpty()) {
|
||||
for (int i = hitParticles.size() - 1; i >= 0; i--) {
|
||||
HitParticle p = hitParticles.get(i);
|
||||
p.update(delta);
|
||||
if (!p.isAlive())
|
||||
hitParticles.remove(i);
|
||||
}
|
||||
}
|
||||
// 更新环形与火花
|
||||
for (int i = radialRings.size() - 1; i >= 0; i--) {
|
||||
RadialRing r = radialRings.get(i);
|
||||
r.update(delta);
|
||||
if (!r.isAlive()) radialRings.remove(i);
|
||||
}
|
||||
for (int i = sparkParticles.size() - 1; i >= 0; i--) {
|
||||
SparkParticle sp = sparkParticles.get(i);
|
||||
sp.update(delta);
|
||||
if (!sp.isAlive()) sparkParticles.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地与远程玩家之间简单碰撞(防穿人)——放在摄像机更新前
|
||||
if (!gameOver) {
|
||||
resolvePlayerCollisions();
|
||||
clampToWorld(player);
|
||||
for (SimpleFighter op : otherPlayers.values()) clampToWorld(op);
|
||||
}
|
||||
|
||||
// ================== Game Over 判定 ==================
|
||||
if (!gameOver) {
|
||||
boolean playerDead = !player.isAlive();
|
||||
boolean allOthersDead = true;
|
||||
for (SimpleFighter op : otherPlayers.values()) {
|
||||
if (op.isAlive()) {
|
||||
allOthersDead = false; // 有至少一个活着
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (playerDead || (!otherPlayers.isEmpty() && allOthersDead)) {
|
||||
gameOver = true;
|
||||
if (playerDead) {
|
||||
// 找第一个存活的对手
|
||||
String wn = "对手";
|
||||
for (SimpleFighter op : otherPlayers.values()) {
|
||||
if (op.isAlive()) {
|
||||
wn = op.getName();
|
||||
break;
|
||||
}
|
||||
}
|
||||
winnerName = wn;
|
||||
} else {
|
||||
winnerName = player.getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 摄像机跟随
|
||||
// 摄像头跟随:若有一个远程玩家,则居中于本地和远程玩家中点
|
||||
Vector3 targetPos;
|
||||
if (otherPlayers.size() == 1) {
|
||||
SimpleFighter remote = otherPlayers.values().iterator().next();
|
||||
float midX = (player.getHitbox().x + remote.getHitbox().x) * 0.5f;
|
||||
float midY = (player.getHitbox().y + remote.getHitbox().y) * 0.5f;
|
||||
targetPos = new Vector3(midX, midY + CAMERA_Y_OFFSET, 0);
|
||||
} else {
|
||||
// 默认为跟随本地玩家
|
||||
targetPos = new Vector3(player.getHitbox().x, player.getHitbox().y + CAMERA_Y_OFFSET, 0);
|
||||
}
|
||||
// 仅对 x,y 做分量插值,可单独调节速度
|
||||
camera.position.x += (targetPos.x - camera.position.x) * CAMERA_LERP_ALPHA;
|
||||
camera.position.y += (targetPos.y - camera.position.y) * (CAMERA_LERP_ALPHA * 1.1f);
|
||||
// 计算动态缩放目标
|
||||
if (CAMERA_DYNAMIC_ZOOM && otherPlayers.size() >= 1) {
|
||||
// 取所有玩家(本地 + 远程)x 坐标的最大跨度作为距离依据,可扩展为对角线距离
|
||||
float minX = player.getHitbox().x;
|
||||
float maxX = player.getHitbox().x;
|
||||
float minY = player.getHitbox().y;
|
||||
float maxY = player.getHitbox().y;
|
||||
for (SimpleFighter r : otherPlayers.values()) {
|
||||
Rectangle hb = r.getHitbox();
|
||||
if (hb.x < minX)
|
||||
minX = hb.x;
|
||||
if (hb.x > maxX)
|
||||
maxX = hb.x;
|
||||
if (hb.y < minY)
|
||||
minY = hb.y;
|
||||
if (hb.y > maxY)
|
||||
maxY = hb.y;
|
||||
}
|
||||
float dx = maxX - minX;
|
||||
float dy = maxY - minY;
|
||||
// 这里主要横向对战,优先 dx;若想考虑纵向可用距离 = max(dx, dy*系数)
|
||||
float dist = Math.max(dx, dy * 0.6f);
|
||||
float t = Math.min(1f, dist / CAMERA_MAX_DISTANCE); // 0~1
|
||||
float targetZoom = CAMERA_MIN_ZOOM + (CAMERA_MAX_ZOOM - CAMERA_MIN_ZOOM) * t;
|
||||
camera.zoom += (targetZoom - camera.zoom) * CAMERA_ZOOM_LERP;
|
||||
} else if (CAMERA_USE_FIXED_ZOOM) {
|
||||
// 固定缩放平滑
|
||||
if (Math.abs(camera.zoom - CAMERA_FIXED_ZOOM) > 0.0001f) {
|
||||
camera.zoom += (CAMERA_FIXED_ZOOM - camera.zoom) * 0.25f;
|
||||
}
|
||||
}
|
||||
// 约束摄像机在世界边界内(视口以中心为基准)
|
||||
float halfW = (camera.viewportWidth * camera.zoom) / 2f;
|
||||
float halfH = (camera.viewportHeight * camera.zoom) / 2f;
|
||||
camera.position.x = Math.max(halfW, Math.min(worldWidth - halfW, camera.position.x));
|
||||
float minY = halfH - CAMERA_BOTTOM_MARGIN; // 允许比世界底部再低一些显示底部区域
|
||||
camera.position.y = Math.max(minY, Math.min(worldHeight - halfH, camera.position.y));
|
||||
camera.update();
|
||||
batch.setProjectionMatrix(camera.combined);
|
||||
shapeRenderer.setProjectionMatrix(camera.combined);
|
||||
|
||||
// -------- Background pass --------
|
||||
batch.begin();
|
||||
// 仅绘制背景的局部:通过在更大的缩放空间中直接拉伸整张图并限制摄像机
|
||||
// 若想真正裁剪一部分,可改用纹理区域;此处使用整图放大后让摄像机在其内游走
|
||||
batch.draw(background,
|
||||
0, 0,
|
||||
worldWidth, worldHeight);
|
||||
batch.end();
|
||||
|
||||
// 混合
|
||||
Gdx.gl.glEnable(GL20.GL_BLEND);
|
||||
Gdx.gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
// -------- Filled pass --------
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
// 半透明地面条带(先于 hitbox 绘制,这样人物在其上方)
|
||||
if (SHOW_GROUND_STRIP) {
|
||||
float y = GameConstants.GROUND_Y - GROUND_STRIP_HEIGHT * 0.5f; // 居中穿过地面线
|
||||
shapeRenderer.setColor(0.9f, 0.9f, 0.9f, GROUND_STRIP_ALPHA); // 近白轻薄层
|
||||
shapeRenderer.rect(0f, y, worldWidth, GROUND_STRIP_HEIGHT);
|
||||
}
|
||||
// 先绘制攻击特效(填充)
|
||||
for (AttackEffect ef : attackEffects) {
|
||||
ef.render(shapeRenderer);
|
||||
}
|
||||
// 再绘制命中粒子
|
||||
for (HitParticle hp : hitParticles) {
|
||||
hp.render(shapeRenderer);
|
||||
}
|
||||
for (RadialRing rr : radialRings) rr.render(shapeRenderer);
|
||||
for (SparkParticle sp : sparkParticles) sp.render(shapeRenderer);
|
||||
drawHitbox(player, player.getDebugColor());
|
||||
boolean showPlayerAttack = player.isAttacking()
|
||||
|| (player.getCurrentAction() == Action.ATTACK && player.getAttackTimer() > 0);
|
||||
if (showPlayerAttack) {
|
||||
// 分阶段颜色:startup=淡青, active=亮蓝, recovery=灰蓝
|
||||
if (player.isInStartupPhase()) {
|
||||
drawAttackBox(player, 0.3f, 0.8f, 1f, 0.18f);
|
||||
} else if (player.isInActivePhase()) {
|
||||
drawAttackBox(player, 0.0f, 0.6f, 1f, 0.45f);
|
||||
} else if (player.isInRecoveryPhase()) {
|
||||
drawAttackBox(player, 0.2f, 0.45f, 0.8f, 0.25f);
|
||||
} else {
|
||||
drawAttackBox(player, 0.0f, 0.45f, 1f, 0.35f);
|
||||
}
|
||||
}
|
||||
// 防御白线(填充 pass 里画一条粗线模拟盾)
|
||||
if (player.isDefending()) {
|
||||
float lineX = player.isFacingRight() ? player.getHitbox().x + player.getHitbox().width + 4f
|
||||
: player.getHitbox().x - 6f;
|
||||
shapeRenderer.setColor(1f, 1f, 1f, 0.85f);
|
||||
shapeRenderer.rect(lineX, player.getHitbox().y + 18f, 4f * (player.isFacingRight() ? 1f : -1f),
|
||||
player.getHitbox().height - 36f);
|
||||
}
|
||||
|
||||
for (SimpleFighter remote : otherPlayers.values()) {
|
||||
drawHitbox(remote, remote.getDebugColor());
|
||||
if (remote.isAttacking()) {
|
||||
if (remote.isInStartupPhase()) {
|
||||
drawAttackBox(remote, 1f, 0.75f, 0.25f, 0.18f);
|
||||
} else if (remote.isInActivePhase()) {
|
||||
drawAttackBox(remote, 1f, 0.55f, 0.10f, 0.45f);
|
||||
} else if (remote.isInRecoveryPhase()) {
|
||||
drawAttackBox(remote, 0.8f, 0.4f, 0.15f, 0.25f);
|
||||
} else {
|
||||
drawAttackBox(remote, 1f, 0.6f, 0.2f, 0.30f);
|
||||
}
|
||||
}
|
||||
if (remote.isDefending()) {
|
||||
float lineX = remote.isFacingRight() ? remote.getHitbox().x + remote.getHitbox().width + 4f
|
||||
: remote.getHitbox().x - 6f;
|
||||
shapeRenderer.setColor(1f, 1f, 1f, 0.75f);
|
||||
shapeRenderer.rect(lineX, remote.getHitbox().y + 18f, 4f * (remote.isFacingRight() ? 1f : -1f),
|
||||
remote.getHitbox().height - 36f);
|
||||
}
|
||||
}
|
||||
shapeRenderer.end();
|
||||
|
||||
// -------- Sprite pass --------
|
||||
batch.begin();
|
||||
// 角色精灵(当前占位),未来可在这里设置 batch.setColor(alpha) 来影响纹理
|
||||
batch.setColor(1f, 1f, 1f, player.getRenderAlpha());
|
||||
player.renderSprite(batch);
|
||||
batch.setColor(1f, 1f, 1f, 1f);
|
||||
batch.end();
|
||||
|
||||
// -------- Debug line pass --------
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Line);
|
||||
if (SHOW_GROUND_LINE) {
|
||||
shapeRenderer.setColor(0.65f, 0.65f, 0.65f, 1f); // 浅灰色参考线
|
||||
shapeRenderer.line(0f, GameConstants.GROUND_Y, worldWidth, GameConstants.GROUND_Y);
|
||||
}
|
||||
// 攻击特效描边
|
||||
for (AttackEffect ef : attackEffects) {
|
||||
ef.renderOutline(shapeRenderer);
|
||||
}
|
||||
player.renderDebug(shapeRenderer);
|
||||
for (SimpleFighter remote : otherPlayers.values())
|
||||
remote.renderDebug(shapeRenderer);
|
||||
shapeRenderer.end();
|
||||
// -------- UI health bar pass --------
|
||||
// 使用屏幕坐标绘制血条
|
||||
OrthographicCamera uiCam = new OrthographicCamera(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
|
||||
uiCam.setToOrtho(false);
|
||||
uiCam.update();
|
||||
shapeRenderer.setProjectionMatrix(uiCam.combined);
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
// 绘制本地玩家血条(左侧)和所有远程玩家血条(右侧依次排列)
|
||||
float barWidth = 320f, barHeight = 22f, padding = 18f; // 放大并下移
|
||||
float screenW = Gdx.graphics.getWidth(), screenH = Gdx.graphics.getHeight();
|
||||
float baseY = screenH - padding - barHeight - 20f; // 下移 20 避免贴顶
|
||||
// 本地玩家血条
|
||||
shapeRenderer.setColor(0.15f, 0.15f, 0.15f, 0.9f);
|
||||
shapeRenderer.rect(padding, baseY, barWidth, barHeight);
|
||||
shapeRenderer.setColor(Color.RED);
|
||||
shapeRenderer.rect(padding, baseY,
|
||||
barWidth * (player.getHealth() / (float) player.getMaxHealth()), barHeight);
|
||||
// 远程玩家血条
|
||||
int idx = 0;
|
||||
for (SimpleFighter remote : otherPlayers.values()) {
|
||||
float x = screenW - padding - barWidth - idx * (barWidth + padding);
|
||||
shapeRenderer.setColor(0.15f, 0.15f, 0.15f, 0.9f);
|
||||
shapeRenderer.rect(x, baseY, barWidth, barHeight);
|
||||
shapeRenderer.setColor(Color.GREEN);
|
||||
float ratio = remote.getHealth() / (float) remote.getMaxHealth();
|
||||
shapeRenderer.rect(x, baseY,
|
||||
barWidth * ratio, barHeight);
|
||||
idx++;
|
||||
}
|
||||
shapeRenderer.end();
|
||||
|
||||
// ================== Game Over 覆盖层 ==================
|
||||
if (gameOver) {
|
||||
// 使用同一 UI 摄像机
|
||||
shapeRenderer.setProjectionMatrix(uiCam.combined);
|
||||
batch.setProjectionMatrix(uiCam.combined);
|
||||
float screenW2 = screenW;
|
||||
float screenH2 = screenH;
|
||||
float panelX = (screenW2 - GO_PANEL_W) / 2f;
|
||||
float panelY = (screenH2 - GO_PANEL_H) / 2f;
|
||||
float btnRestartX = panelX + (GO_PANEL_W - GO_BUTTON_W) / 2f;
|
||||
float btnRestartY = panelY + 110f;
|
||||
float btnMenuX = btnRestartX;
|
||||
float btnMenuY = panelY + 25f;
|
||||
|
||||
// 半透明遮罩 + 面板 + 按钮
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
shapeRenderer.setColor(0, 0, 0, 0.55f);
|
||||
shapeRenderer.rect(0, 0, screenW2, screenH2);
|
||||
shapeRenderer.setColor(0.12f, 0.12f, 0.12f, 0.92f);
|
||||
shapeRenderer.rect(panelX, panelY, GO_PANEL_W, GO_PANEL_H);
|
||||
// 按钮背景
|
||||
int mx = Gdx.input.getX();
|
||||
int my = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
boolean hoverRestart = mx >= btnRestartX && mx <= btnRestartX + GO_BUTTON_W && my >= btnRestartY
|
||||
&& my <= btnRestartY + GO_BUTTON_H;
|
||||
boolean hoverMenu = mx >= btnMenuX && mx <= btnMenuX + GO_BUTTON_W && my >= btnMenuY
|
||||
&& my <= btnMenuY + GO_BUTTON_H;
|
||||
shapeRenderer.setColor(
|
||||
hoverRestart ? new Color(0.25f, 0.5f, 0.25f, 0.95f) : new Color(0.18f, 0.38f, 0.18f, 0.9f));
|
||||
shapeRenderer.rect(btnRestartX, btnRestartY, GO_BUTTON_W, GO_BUTTON_H);
|
||||
shapeRenderer
|
||||
.setColor(hoverMenu ? new Color(0.35f, 0.35f, 0.55f, 0.95f) : new Color(0.25f, 0.25f, 0.45f, 0.9f));
|
||||
shapeRenderer.rect(btnMenuX, btnMenuY, GO_BUTTON_W, GO_BUTTON_H);
|
||||
shapeRenderer.end();
|
||||
|
||||
// 文本
|
||||
batch.begin();
|
||||
if (uiFont != null) {
|
||||
uiFont.draw(batch, "游戏结束", panelX + 180f, panelY + GO_PANEL_H - 60f);
|
||||
uiFont.draw(batch, "胜者: " + winnerName, panelX + 180f, panelY + GO_PANEL_H - 120f);
|
||||
uiFont.draw(batch, "重新开始", btnRestartX + 40f, btnRestartY + 48f);
|
||||
uiFont.draw(batch, "返回主界面", btnMenuX + 20f, btnMenuY + 48f);
|
||||
}
|
||||
batch.end();
|
||||
|
||||
// 按钮交互(鼠标)
|
||||
if (Gdx.input.justTouched()) {
|
||||
int mx2 = Gdx.input.getX();
|
||||
int my2 = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
if (mx2 >= btnRestartX && mx2 <= btnRestartX + GO_BUTTON_W && my2 >= btnRestartY
|
||||
&& my2 <= btnRestartY + GO_BUTTON_H) {
|
||||
restartGame();
|
||||
return; // 避免继续处理后续逻辑
|
||||
} else if (mx2 >= btnMenuX && mx2 <= btnMenuX + GO_BUTTON_W && my2 >= btnMenuY
|
||||
&& my2 <= btnMenuY + GO_BUTTON_H) {
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 键盘快捷键:R 重新开始 / M 或 ESC 返回菜单
|
||||
if (Gdx.input.isKeyJustPressed(com.badlogic.gdx.Input.Keys.R)) {
|
||||
restartGame();
|
||||
} else if (Gdx.input.isKeyJustPressed(com.badlogic.gdx.Input.Keys.M)
|
||||
|| Gdx.input.isKeyJustPressed(com.badlogic.gdx.Input.Keys.ESCAPE)) {
|
||||
returnToMenu();
|
||||
}
|
||||
}
|
||||
// 原定期性能日志已移除(日志系统删除)
|
||||
}
|
||||
|
||||
private void drawHitbox(SimpleFighter fighter, Color color) {
|
||||
float a = fighter.getRenderAlpha();
|
||||
if (a <= 0f)
|
||||
return;
|
||||
// 阵营基色:本地玩家=青蓝(#26BFF2),敌方=橙红(#F35A26);半透明以免过亮
|
||||
boolean isLocal = fighter == player;
|
||||
float br = isLocal ? 0.15f : 0.95f;
|
||||
float bg = isLocal ? 0.75f : 0.35f;
|
||||
float bb = isLocal ? 0.95f : 0.15f;
|
||||
shapeRenderer.setColor(br, bg, bb, a * 0.85f);
|
||||
Rectangle r = fighter.getHitbox();
|
||||
float scale = GameConstants.DEBUG_BOX_SCALE;
|
||||
if (scale <= 1.0001f && scale >= 0.9999f) { // 视为 1
|
||||
shapeRenderer.rect(r.x, r.y, r.width, r.height);
|
||||
return;
|
||||
}
|
||||
float cx = r.x + r.width / 2f;
|
||||
float cy = r.y + r.height / 2f;
|
||||
float w = r.width * scale;
|
||||
float h = r.height * scale;
|
||||
if (GameConstants.DEBUG_SCALE_FROM_CENTER) {
|
||||
shapeRenderer.rect(cx - w / 2f, cy - h / 2f, w, h);
|
||||
} else {
|
||||
shapeRenderer.rect(r.x, r.y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawAttackBox(SimpleFighter fighter, float r, float g, float b, float a) {
|
||||
float alpha = fighter.getRenderAlpha();
|
||||
if (alpha <= 0f)
|
||||
return;
|
||||
boolean isLocal = fighter == player;
|
||||
// 阵营色调
|
||||
float teamR = isLocal ? 0.2f : 0.9f;
|
||||
float teamG = isLocal ? 0.9f : 0.3f;
|
||||
float teamB = isLocal ? 1.0f : 0.2f;
|
||||
// 将阶段色与阵营色混合:阶段色 55%,阵营色 45%
|
||||
float cr = r * 0.55f + teamR * 0.45f;
|
||||
float cg = g * 0.55f + teamG * 0.45f;
|
||||
float cb = b * 0.55f + teamB * 0.45f;
|
||||
shapeRenderer.setColor(cr, cg, cb, a * alpha);
|
||||
Rectangle box = fighter.getAttackbox();
|
||||
float scale = GameConstants.DEBUG_BOX_SCALE;
|
||||
if (scale <= 1.0001f && scale >= 0.9999f) {
|
||||
shapeRenderer.rect(box.x, box.y, box.width, box.height);
|
||||
return;
|
||||
}
|
||||
float cx = box.x + box.width / 2f;
|
||||
float cy = box.y + box.height / 2f;
|
||||
float w = box.width * scale;
|
||||
float h = box.height * scale;
|
||||
if (GameConstants.DEBUG_SCALE_FROM_CENTER) {
|
||||
shapeRenderer.rect(cx - w / 2f, cy - h / 2f, w, h);
|
||||
} else {
|
||||
shapeRenderer.rect(box.x, box.y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
// private void checkPlayerAttacks() {
|
||||
// if (!player.isAttacking()) return;
|
||||
|
||||
// for (SimpleFighter target : otherPlayers.values()) {
|
||||
// if (target.isAlive() && player.getAttackbox().overlaps(target.getHitbox())) {
|
||||
// target.takeHit(player.getAttackPower()); // 使用访问器方法
|
||||
// System.out.println("命中远程玩家:" + target.getName());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
batch.dispose();
|
||||
shapeRenderer.dispose();
|
||||
if (background != null)
|
||||
background.dispose();
|
||||
if (uiFont != null)
|
||||
uiFont.dispose();
|
||||
NetworkManager.getInstance().disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解决本地玩家与每个远程玩家的水平重叠,避免“穿人”视觉效果。
|
||||
* 当前策略:仅移动本地玩家(不修改远程玩家坐标,避免产生需要网络回传的状态)。
|
||||
* 若需要更严格的对等碰撞,可在主机端双向分离并广播,但此处先满足基本需求。
|
||||
*/
|
||||
private void resolvePlayerCollisions() {
|
||||
if (otherPlayers.isEmpty())
|
||||
return;
|
||||
Rectangle a = player.getHitbox();
|
||||
for (SimpleFighter remote : otherPlayers.values()) {
|
||||
Rectangle b = remote.getHitbox();
|
||||
if (!a.overlaps(b))
|
||||
continue;
|
||||
// 仅考虑水平最小位移分离(2D 侧向格斗常见做法)
|
||||
float axCenter = a.x + a.width / 2f;
|
||||
float bxCenter = b.x + b.width / 2f;
|
||||
float dx = axCenter - bxCenter; // 正值:本地在右侧
|
||||
float overlapX = (a.width + b.width) / 2f - Math.abs(dx);
|
||||
if (overlapX > 0) {
|
||||
if (dx >= 0) {
|
||||
a.x += overlapX; // 本地向右推
|
||||
} else {
|
||||
a.x -= overlapX; // 本地向左推
|
||||
}
|
||||
// 世界边界限制(假设世界从 0 开始到 worldWidth)
|
||||
if (a.x < 0)
|
||||
a.x = 0;
|
||||
if (a.x + a.width > worldWidth)
|
||||
a.x = worldWidth - a.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 限制角色 hitbox 不超出世界边界。 */
|
||||
private void clampToWorld(SimpleFighter f) {
|
||||
if (f == null) return;
|
||||
Rectangle hb = f.getHitbox();
|
||||
if (hb.x < 0) hb.x = 0;
|
||||
if (hb.x + hb.width > worldWidth) hb.x = worldWidth - hb.width;
|
||||
// 垂直:地面以上,顶部不超背景
|
||||
if (hb.y < GameConstants.GROUND_Y) hb.y = GameConstants.GROUND_Y;
|
||||
if (hb.y + hb.height > worldHeight) hb.y = worldHeight - hb.height;
|
||||
}
|
||||
|
||||
private void restartGame() {
|
||||
// 重新实例化一个同类型的玩家
|
||||
SimpleFighter newPlayer;
|
||||
if (player instanceof AdvancedFighter) {
|
||||
newPlayer = new AdvancedFighter(player.getName());
|
||||
} else {
|
||||
newPlayer = new SimpleFighter(player.getName());
|
||||
}
|
||||
game.setScreen(new GameScreen(game, newPlayer));
|
||||
}
|
||||
|
||||
private void returnToMenu() {
|
||||
game.setScreen(new MainMenuScreen(game));
|
||||
}
|
||||
|
||||
/**
|
||||
* 让 fighter 朝向 map 中最近的一个对手(按 X 中心差)。
|
||||
* 约束:1) 若自身正在攻击 ACTIVE 阶段则不翻转,避免攻击盒闪跳。
|
||||
*/
|
||||
private void autoFace(SimpleFighter self, Map<String, SimpleFighter> candidates) {
|
||||
if (self == null || candidates == null || candidates.isEmpty())
|
||||
return;
|
||||
// 若正在攻击:仅允许在 STARTUP 之前或 RECOVERY 后翻转,这里简单:只要在攻击整体期间就不改
|
||||
if (self.isAttacking())
|
||||
return;
|
||||
float selfCenter = self.getHitbox().x + self.getHitbox().width * 0.5f;
|
||||
SimpleFighter nearest = null;
|
||||
float bestDist = Float.MAX_VALUE;
|
||||
for (SimpleFighter f : candidates.values()) {
|
||||
if (f == null || f == self)
|
||||
continue;
|
||||
float center = f.getHitbox().x + f.getHitbox().width * 0.5f;
|
||||
float d = Math.abs(center - selfCenter);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
nearest = f;
|
||||
}
|
||||
}
|
||||
if (nearest != null) {
|
||||
float targetCenter = nearest.getHitbox().x + nearest.getHitbox().width * 0.5f;
|
||||
boolean shouldFaceRight = targetCenter >= selfCenter;
|
||||
self.setFacingRight(shouldFaceRight);
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据命中位置和攻击类型生成粒子喷射。 */
|
||||
private void spawnHitParticles(SimpleFighter target, String attackType, boolean attackerFacingRight) {
|
||||
if (target == null)
|
||||
return;
|
||||
// 命中点设为目标上半身区域
|
||||
float cx = target.getHitbox().x + target.getHitbox().width * 0.5f;
|
||||
float cy = target.getHitbox().y + target.getHitbox().height * 0.6f;
|
||||
int count;
|
||||
Color base;
|
||||
switch (attackType) {
|
||||
case "heavy":
|
||||
count = 28;
|
||||
base = new Color(1f, 0.55f, 0.20f, 1f);
|
||||
break;
|
||||
case "special":
|
||||
count = 40;
|
||||
base = new Color(0.9f, 0.25f, 0.95f, 1f);
|
||||
break;
|
||||
case "light":
|
||||
default:
|
||||
count = 18;
|
||||
base = new Color(1f, 0.85f, 0.25f, 1f);
|
||||
break;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
hitParticles.add(new HitParticle(cx, cy, base,
|
||||
260f, 520f, // 速度范围
|
||||
0.25f, 0.55f, // 寿命范围
|
||||
4f, 10f // 尺寸范围
|
||||
));
|
||||
}
|
||||
// 额外冲击条纹(用 AttackEffect 再叠一层细长)可选:略
|
||||
}
|
||||
|
||||
/** 攻击进入 ACTIVE 时的环形+火花特效。 */
|
||||
private void spawnActivationFX(SimpleFighter fighter, boolean isLocal) {
|
||||
Rectangle ab = fighter.getAttackbox();
|
||||
float cx = ab.x + ab.width * 0.5f;
|
||||
float cy = ab.y + ab.height * 0.5f;
|
||||
// 环颜色区分阵营
|
||||
Color ringColor = isLocal ? new Color(0.35f, 0.8f, 1f, 0.9f) : new Color(1f, 0.55f, 0.2f, 0.85f);
|
||||
radialRings.add(new RadialRing(cx, cy, 6f, Math.max(38f, ab.width * 0.55f), 0.28f, ringColor));
|
||||
// 火花数量按攻击盒面积估计
|
||||
int sparkCount = 14 + (int) (ab.width * ab.height / 800f);
|
||||
Color sparkBase = isLocal ? new Color(0.55f, 0.9f, 1f, 1f) : new Color(1f, 0.7f, 0.25f, 1f);
|
||||
for (int i = 0; i < sparkCount; i++) {
|
||||
float ang = (float) (Math.random() * Math.PI * 2);
|
||||
float speed = 420f + (float) Math.random() * 380f;
|
||||
float life = 0.18f + (float) Math.random() * 0.16f;
|
||||
float len = 6f + (float) Math.random() * 10f;
|
||||
// 色彩轻度扰动
|
||||
Color c = new Color(
|
||||
clamp01(sparkBase.r + (float) (Math.random() * 0.15 - 0.075)),
|
||||
clamp01(sparkBase.g + (float) (Math.random() * 0.15 - 0.075)),
|
||||
clamp01(sparkBase.b + (float) (Math.random() * 0.15 - 0.075)), 1f);
|
||||
sparkParticles.add(new SparkParticle(cx, cy, speed, ang, life, len, c));
|
||||
}
|
||||
}
|
||||
|
||||
private float clamp01(float v) { return v < 0f ? 0f : (v > 1f ? 1f : v); }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
|
||||
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;
|
||||
|
||||
public class Launcher {
|
||||
public static void main(String[] args) {
|
||||
Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration();
|
||||
configuration.setTitle("Test Game");
|
||||
configuration.setWindowedMode(800, 600);
|
||||
configuration.setForegroundFPS(60);
|
||||
configuration.useVsync(true);
|
||||
new Lwjgl3Application(new GameCore(), configuration);
|
||||
}
|
||||
}
|
||||
38
src/main/java/uno/mloluyu/desktop/MainGame.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Game;
|
||||
|
||||
public class MainGame extends Game {
|
||||
public static final float WORLD_WIDTH = 1920;
|
||||
public static final float WORLD_HEIGHT = 1080;
|
||||
private StartScreen startScreen;
|
||||
private MainMenuScreen mainMenuScreen;
|
||||
|
||||
@Override
|
||||
public void create() {
|
||||
startScreen = new StartScreen(this);
|
||||
mainMenuScreen = new MainMenuScreen(this);
|
||||
setScreen(new MainMenuScreen(this));
|
||||
setScreen(startScreen);
|
||||
}
|
||||
|
||||
public void showGameScreen() {
|
||||
setScreen(mainMenuScreen);
|
||||
if (startScreen != null) {
|
||||
startScreen.dispose();
|
||||
startScreen = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
if (startScreen != null) {
|
||||
startScreen.dispose();
|
||||
startScreen = null;
|
||||
}
|
||||
if (mainMenuScreen != null) {
|
||||
mainMenuScreen.dispose();
|
||||
mainMenuScreen = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
112
src/main/java/uno/mloluyu/desktop/MainMenuScreen.java
Normal file
@@ -0,0 +1,112 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.GL20;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.graphics.g2d.BitmapFont;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
|
||||
import static uno.mloluyu.util.Font.loadChineseFont;
|
||||
|
||||
public class MainMenuScreen extends ScreenAdapter {
|
||||
|
||||
private final MainGame game;
|
||||
private SpriteBatch batch;
|
||||
private BitmapFont font;
|
||||
private ShapeRenderer shapeRenderer;
|
||||
private Texture texture;
|
||||
|
||||
// 按钮区域
|
||||
private final int buttonWidth = 600;
|
||||
private final int buttonHeight = 80;
|
||||
private final int buttonX = 760;
|
||||
private final int startY = 600;
|
||||
private final int settingsY = 480;
|
||||
private final int networkY = 360;
|
||||
private final int escY = 240;
|
||||
|
||||
public MainMenuScreen(MainGame game) {
|
||||
this.game = game;
|
||||
texture = new Texture(Gdx.files.internal("bg.png"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
batch = new SpriteBatch();
|
||||
shapeRenderer = new ShapeRenderer();
|
||||
font = loadChineseFont();
|
||||
font.setColor(Color.WHITE);
|
||||
font.getData().setScale(2f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
Gdx.gl.glClearColor(0, 0, 0, 0);
|
||||
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
|
||||
|
||||
int mouseX = Gdx.input.getX();
|
||||
int mouseY = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
|
||||
// 绘制按钮背景
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
drawButtonBox(startY, "开始游戏", mouseX, mouseY);
|
||||
drawButtonBox(settingsY, "设置", mouseX, mouseY);
|
||||
drawButtonBox(networkY, "联网设置", mouseX, mouseY);
|
||||
drawButtonBox(escY, "退出游戏", mouseX, mouseY);
|
||||
shapeRenderer.end();
|
||||
|
||||
// 绘制按钮文字
|
||||
batch.begin();
|
||||
batch.draw(texture, 0, 0, 1920, 1080);
|
||||
drawButtonText(startY, "开始游戏");
|
||||
drawButtonText(settingsY, "设置");
|
||||
drawButtonText(networkY, "联网设置");
|
||||
drawButtonText(escY, "退出游戏");
|
||||
batch.end();
|
||||
|
||||
// 点击事件
|
||||
if (Gdx.input.justTouched()) {
|
||||
if (isTouched(mouseX, mouseY, buttonX, startY)) {
|
||||
Gdx.app.log("Button", "开始游戏按钮被点击!");
|
||||
game.setScreen(new CharacterSelectScreen(game));
|
||||
} else if (isTouched(mouseX, mouseY, buttonX, settingsY)) {
|
||||
Gdx.app.log("Button", "设置按钮被点击!");
|
||||
game.setScreen(new SettingsScreen(game));
|
||||
} else if (isTouched(mouseX, mouseY, buttonX, networkY)) {
|
||||
Gdx.app.log("Button", "联网设置按钮被点击!");
|
||||
game.setScreen(new NetworkSettingsScreen(game));
|
||||
} else if (isTouched(mouseX, mouseY, buttonX, escY)) {
|
||||
Gdx.app.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawButtonBox(int y, String label, int mouseX, int mouseY) {
|
||||
boolean hovered = isTouched(mouseX, mouseY, buttonX, y);
|
||||
shapeRenderer.setColor(hovered ? Color.LIGHT_GRAY : Color.DARK_GRAY);
|
||||
shapeRenderer.rect(buttonX, y, buttonWidth, buttonHeight);
|
||||
}
|
||||
|
||||
private void drawButtonText(int y, String text) {
|
||||
float textX = buttonX + buttonWidth / 2f - text.length() * 20; // 简单估算居中
|
||||
float textY = y + buttonHeight / 2f + 20;
|
||||
font.draw(batch, text, textX, textY);
|
||||
}
|
||||
|
||||
private boolean isTouched(int x, int y, int bx, int by) {
|
||||
return x >= bx && x <= bx + buttonWidth && y >= by && y <= by + buttonHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
if (batch != null)
|
||||
batch.dispose();
|
||||
if (font != null)
|
||||
font.dispose();
|
||||
if (shapeRenderer != null)
|
||||
shapeRenderer.dispose();
|
||||
}
|
||||
}
|
||||
154
src/main/java/uno/mloluyu/desktop/NetworkSettingsScreen.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.graphics.g2d.BitmapFont;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
|
||||
import uno.mloluyu.network.NetworkManager;
|
||||
import static uno.mloluyu.util.Font.loadChineseFont;
|
||||
import uno.mloluyu.util.ClearScreen;
|
||||
|
||||
public class NetworkSettingsScreen extends ScreenAdapter {
|
||||
private final MainGame game;
|
||||
private SpriteBatch batch;
|
||||
private BitmapFont font;
|
||||
private ShapeRenderer shapeRenderer;
|
||||
|
||||
private static final int BUTTON_WIDTH = 400;
|
||||
private static final int BUTTON_HEIGHT = 80;
|
||||
private static final int BUTTON_X = 760;
|
||||
private static final int CREATE_ROOM_Y = 500;
|
||||
private static final int JOIN_ROOM_Y = 380;
|
||||
private static final int EXIT_Y = 260;
|
||||
|
||||
public NetworkSettingsScreen(MainGame game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
batch = new SpriteBatch();
|
||||
shapeRenderer = new ShapeRenderer();
|
||||
font = loadChineseFont();
|
||||
font.setColor(Color.WHITE);
|
||||
font.getData().setScale(2f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
// 使用静态工具方法清屏
|
||||
ClearScreen.clear();
|
||||
|
||||
int mouseX = Gdx.input.getX();
|
||||
int mouseY = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
|
||||
renderButtons(mouseX, mouseY);
|
||||
renderTexts();
|
||||
handleInput(mouseX, mouseY);
|
||||
}
|
||||
|
||||
private void renderButtons(int mouseX, int mouseY) {
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
drawButton(CREATE_ROOM_Y, mouseX, mouseY);
|
||||
drawButton(JOIN_ROOM_Y, mouseX, mouseY);
|
||||
drawButton(EXIT_Y, mouseX, mouseY);
|
||||
shapeRenderer.end();
|
||||
}
|
||||
|
||||
private void renderTexts() {
|
||||
batch.begin();
|
||||
font.draw(batch, "联机设置", BUTTON_X + 100, 650);
|
||||
drawButtonText(CREATE_ROOM_Y, "创建房间");
|
||||
drawButtonText(JOIN_ROOM_Y, "加入房间");
|
||||
drawButtonText(EXIT_Y, "返回");
|
||||
// 状态信息
|
||||
NetworkManager nm = NetworkManager.getInstance();
|
||||
String id = nm.getLocalPlayerId();
|
||||
font.draw(batch, "本机ID: " + (id == null ? "(未分配)" : id.substring(0, Math.min(8, id.length()))), 50, 200);
|
||||
font.draw(batch, nm.isHost() ? "当前: 房主" : (nm.isConnected() ? "当前: 已连接客户端" : "当前: 未连接"), 50, 160);
|
||||
font.draw(batch, "在线玩家: " + (nm.getPlayerPositions() == null ? 0 : nm.getPlayerPositions().size()), 50, 120);
|
||||
batch.end();
|
||||
}
|
||||
|
||||
private void handleInput(int mouseX, int mouseY) {
|
||||
if (Gdx.input.justTouched()) {
|
||||
// 创建房间
|
||||
if (isHovered(mouseX, mouseY, BUTTON_X, CREATE_ROOM_Y)) {
|
||||
Gdx.app.log("Network", "创建房间按钮被点击!");
|
||||
NetworkManager nm = NetworkManager.getInstance();
|
||||
nm.createRoom(); // 只创建服务器,不自连,等待其他客户端加入
|
||||
if (nm.getLocalPlayerId() == null) {
|
||||
nm.setLocalPlayerId(java.util.UUID.randomUUID().toString());
|
||||
Gdx.app.log("Network", "房主玩家ID: " + nm.getLocalPlayerId());
|
||||
}
|
||||
Gdx.app.log("Network", "房间创建成功,等待客户端加入...");
|
||||
CharacterSelectScreen characterSelectScreen = new CharacterSelectScreen(game);
|
||||
characterSelectScreen.setMultiplayerMode(true);
|
||||
game.setScreen(characterSelectScreen);
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
else if (isHovered(mouseX, mouseY, BUTTON_X, JOIN_ROOM_Y)) {
|
||||
Gdx.app.log("Network", "加入房间按钮被点击!");
|
||||
|
||||
Gdx.input.getTextInput(new com.badlogic.gdx.Input.TextInputListener() {
|
||||
@Override
|
||||
public void input(String ip) {
|
||||
if (ip != null && !ip.trim().isEmpty()) {
|
||||
NetworkManager nm = NetworkManager.getInstance();
|
||||
if (nm.getLocalPlayerId() == null) {
|
||||
nm.setLocalPlayerId(java.util.UUID.randomUUID().toString());
|
||||
Gdx.app.log("Network", "客户端玩家ID: " + nm.getLocalPlayerId());
|
||||
}
|
||||
nm.joinRoom(ip.trim());
|
||||
Gdx.app.log("Network", "正在连接到服务器 " + ip.trim() + "...");
|
||||
|
||||
Gdx.app.postRunnable(() -> {
|
||||
CharacterSelectScreen characterSelectScreen = new CharacterSelectScreen(game);
|
||||
characterSelectScreen.setMultiplayerMode(true);
|
||||
game.setScreen(characterSelectScreen);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void canceled() {
|
||||
Gdx.app.log("Network", "用户取消输入 IP");
|
||||
}
|
||||
}, "请输入服务器 IP 地址", "", "加入房间");
|
||||
}
|
||||
|
||||
// 返回主菜单
|
||||
else if (isHovered(mouseX, mouseY, BUTTON_X, EXIT_Y)) {
|
||||
Gdx.app.log("Network", "退出按钮被点击!");
|
||||
game.setScreen(new MainMenuScreen(game));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawButton(int y, int mouseX, int mouseY) {
|
||||
boolean hovered = isHovered(mouseX, mouseY, BUTTON_X, y);
|
||||
shapeRenderer.setColor(hovered ? Color.LIGHT_GRAY : Color.DARK_GRAY);
|
||||
shapeRenderer.rect(BUTTON_X, y, BUTTON_WIDTH, BUTTON_HEIGHT);
|
||||
}
|
||||
|
||||
private void drawButtonText(int y, String text) {
|
||||
float textX = BUTTON_X + BUTTON_WIDTH / 2f - font.getScaleX() * text.length() * 10;
|
||||
float textY = y + BUTTON_HEIGHT / 2f + 20;
|
||||
font.draw(batch, text, textX, textY);
|
||||
}
|
||||
|
||||
private boolean isHovered(int x, int y, int bx, int by) {
|
||||
return x >= bx && x <= bx + BUTTON_WIDTH && y >= by && y <= by + BUTTON_HEIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
batch.dispose();
|
||||
font.dispose();
|
||||
shapeRenderer.dispose();
|
||||
}
|
||||
}
|
||||
25
src/main/java/uno/mloluyu/desktop/ScreenManager.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
/** 简易屏幕管理器,负责切换与异常保护。 */
|
||||
public class ScreenManager {
|
||||
private final MainGame game;
|
||||
private BaseScreen current;
|
||||
|
||||
public ScreenManager(MainGame game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
public void set(BaseScreen next) {
|
||||
try {
|
||||
if (current != null) {
|
||||
current.hide();
|
||||
current.dispose();
|
||||
}
|
||||
current = next;
|
||||
game.setScreen(next);
|
||||
} catch (Throwable t) {
|
||||
// 忽略或可加简单 System.err
|
||||
t.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/main/java/uno/mloluyu/desktop/SettingsScreen.java
Normal file
@@ -0,0 +1,75 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.ScreenAdapter;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.g2d.BitmapFont;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import uno.mloluyu.util.ClearScreen;
|
||||
import static uno.mloluyu.util.Font.loadChineseFont;
|
||||
|
||||
/**
|
||||
* 简单设置界面占位:未来可扩展(音量/按键设置)。
|
||||
*/
|
||||
public class SettingsScreen extends ScreenAdapter {
|
||||
private final MainGame game;
|
||||
private SpriteBatch batch;
|
||||
private BitmapFont font;
|
||||
private ShapeRenderer shapeRenderer;
|
||||
|
||||
private static final int BACK_X = 100; // 返回按钮区域
|
||||
private static final int BACK_Y = 100;
|
||||
private static final int BACK_W = 220;
|
||||
private static final int BACK_H = 70;
|
||||
|
||||
public SettingsScreen(MainGame game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
batch = new SpriteBatch();
|
||||
shapeRenderer = new ShapeRenderer();
|
||||
font = loadChineseFont();
|
||||
font.setColor(Color.WHITE);
|
||||
font.getData().setScale(2f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
ClearScreen.clear();
|
||||
int mouseX = Gdx.input.getX();
|
||||
int mouseY = Gdx.graphics.getHeight() - Gdx.input.getY();
|
||||
|
||||
// 绘制背景与返回按钮
|
||||
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
|
||||
shapeRenderer.setColor(Color.DARK_GRAY);
|
||||
shapeRenderer.rect(0, 0, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
|
||||
boolean backHover = isHovered(mouseX, mouseY, BACK_X, BACK_Y, BACK_W, BACK_H);
|
||||
shapeRenderer.setColor(backHover ? Color.LIGHT_GRAY : Color.GRAY);
|
||||
shapeRenderer.rect(BACK_X, BACK_Y, BACK_W, BACK_H);
|
||||
shapeRenderer.end();
|
||||
|
||||
batch.begin();
|
||||
font.draw(batch, "设置 (占位界面)", 100, Gdx.graphics.getHeight() - 120);
|
||||
font.draw(batch, "此处可添加: 音量 / 按键 / 分辨率 / 语言 等", 100, Gdx.graphics.getHeight() - 180);
|
||||
font.draw(batch, "返回", BACK_X + 50, BACK_Y + 45);
|
||||
batch.end();
|
||||
|
||||
if (Gdx.input.justTouched() && backHover) {
|
||||
game.setScreen(new MainMenuScreen(game));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHovered(int x, int y, int bx, int by, int bw, int bh) {
|
||||
return x >= bx && x <= bx + bw && y >= by && y <= by + bh;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
batch.dispose();
|
||||
font.dispose();
|
||||
shapeRenderer.dispose();
|
||||
}
|
||||
}
|
||||
80
src/main/java/uno/mloluyu/desktop/StartScreen.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package uno.mloluyu.desktop;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.GL20;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import uno.mloluyu.util.ResourcePaths;
|
||||
|
||||
/**
|
||||
* 启动屏幕类
|
||||
* 显示游戏Logo并在3秒后切换到主菜单界面
|
||||
*/
|
||||
public class StartScreen extends BaseScreen {
|
||||
|
||||
private MainGame mainGame; // TODO: 后续可直接用BaseScreen.game
|
||||
|
||||
private Texture logoTexture;
|
||||
private com.badlogic.gdx.graphics.g2d.SpriteBatch batch;
|
||||
|
||||
private float deltaSum;
|
||||
|
||||
public StartScreen(MainGame mainGame) {
|
||||
super(mainGame);
|
||||
this.mainGame = mainGame;
|
||||
logoTexture = new Texture(Gdx.files.internal(ResourcePaths.LOGO));
|
||||
batch = new com.badlogic.gdx.graphics.g2d.SpriteBatch();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
deltaSum = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(float delta) {
|
||||
deltaSum += delta;
|
||||
|
||||
if (deltaSum >= 3.0F) {
|
||||
if (mainGame != null) {
|
||||
mainGame.showGameScreen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Gdx.gl.glClearColor(1, 1, 1, 1);
|
||||
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
|
||||
|
||||
batch.begin();
|
||||
float x = (MainGame.WORLD_WIDTH - logoTexture.getWidth()) / 2f;
|
||||
float y = (MainGame.WORLD_HEIGHT - logoTexture.getHeight()) / 2f;
|
||||
batch.draw(logoTexture, x, y);
|
||||
batch.end();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resize(int width, int height) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resume() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hide() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
if (batch != null) {
|
||||
batch.dispose();
|
||||
}
|
||||
if (logoTexture != null) {
|
||||
logoTexture.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
69
src/main/java/uno/mloluyu/network/ConnectClient.java
Normal file
@@ -0,0 +1,69 @@
|
||||
package uno.mloluyu.network;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.Net;
|
||||
import com.badlogic.gdx.net.Socket;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 客户端连接类
|
||||
*/
|
||||
public class ConnectClient {
|
||||
private Socket socket;
|
||||
|
||||
public ConnectClient(String ip, int port) {
|
||||
try {
|
||||
socket = Gdx.net.newClientSocket(Net.Protocol.TCP, ip, port, null);
|
||||
Gdx.app.log("Client", "成功连接到服务器: " + ip + ":" + port);
|
||||
|
||||
// 启动接收线程
|
||||
new Thread(this::receiveMessages).start();
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Client", "连接失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void receiveMessages() {
|
||||
try {
|
||||
byte[] buffer = new byte[1024];
|
||||
while (true) {
|
||||
int read = socket.getInputStream().read(buffer);
|
||||
if (read == -1)
|
||||
break;
|
||||
|
||||
String message = new String(buffer, 0, read, StandardCharsets.UTF_8);
|
||||
Gdx.app.log("Client", "收到服务器消息: " + message);
|
||||
|
||||
// 主线程处理消息,避免线程冲突
|
||||
Gdx.app.postRunnable(() -> {
|
||||
NetworkManager.getInstance().receiveMessage(message);
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Client", "接收消息异常: " + e.getMessage(), e);
|
||||
} finally {
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMessage(String message) {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(message.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
Gdx.app.log("Client", "发送消息: " + message);
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Client", "发送消息失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (socket != null) {
|
||||
socket.dispose();
|
||||
socket = null;
|
||||
Gdx.app.log("Client", "已断开与服务器的连接");
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/main/java/uno/mloluyu/network/ConnectServer.java
Normal file
@@ -0,0 +1,121 @@
|
||||
package uno.mloluyu.network;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.Net;
|
||||
import com.badlogic.gdx.net.ServerSocket;
|
||||
import com.badlogic.gdx.net.Socket;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ConnectServer implements Runnable {
|
||||
private final int port;
|
||||
private ServerSocket serverSocket;
|
||||
private final List<Socket> connectedSockets = new ArrayList<>();
|
||||
private static final int MAX_PLAYERS = 2;
|
||||
|
||||
public ConnectServer(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
serverSocket = Gdx.net.newServerSocket(Net.Protocol.TCP, port, null);
|
||||
Gdx.app.log("Server", "服务器已启动,等待玩家连接...");
|
||||
|
||||
try {
|
||||
while (connectedSockets.size() < MAX_PLAYERS) {
|
||||
Socket socket = serverSocket.accept(null);
|
||||
connectedSockets.add(socket);
|
||||
Gdx.app.log("Server", "玩家连接成功: " + socket.getRemoteAddress());
|
||||
// 向新加入的客户端发送当前已有玩家的状态快照(角色选择 + 当前位置)
|
||||
sendSnapshotTo(socket);
|
||||
new Thread(() -> handlePlayer(socket)).start();
|
||||
}
|
||||
|
||||
Gdx.app.log("Server", "已连接两个玩家,游戏准备开始!");
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Server", "连接异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSnapshotTo(Socket socket) {
|
||||
try {
|
||||
NetworkManager nm = NetworkManager.getInstance();
|
||||
// 发送角色选择快照
|
||||
for (java.util.Map.Entry<String, String> e : nm.getPlayerCharacters().entrySet()) {
|
||||
String line = "SELECT:" + e.getKey() + "," + e.getValue();
|
||||
socket.getOutputStream().write(line.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
// 发送位置快照
|
||||
for (java.util.Map.Entry<String, float[]> e : nm.getPlayerPositions().entrySet()) {
|
||||
float[] p = e.getValue();
|
||||
if (p != null && p.length == 2) {
|
||||
String line = "POS:" + e.getKey() + "," + p[0] + "," + p[1];
|
||||
socket.getOutputStream().write(line.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
socket.getOutputStream().flush();
|
||||
Gdx.app.log("Server", "已发送状态快照给新客户端");
|
||||
} catch (Exception ex) {
|
||||
Gdx.app.error("Server", "发送快照失败: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePlayer(Socket socket) {
|
||||
try {
|
||||
byte[] buffer = new byte[1024];
|
||||
while (true) {
|
||||
int read = socket.getInputStream().read(buffer);
|
||||
if (read == -1)
|
||||
break;
|
||||
|
||||
String message = new String(buffer, 0, read, StandardCharsets.UTF_8);
|
||||
Gdx.app.log("Server", "收到消息: " + message);
|
||||
|
||||
broadcastToOthers(socket, message);
|
||||
|
||||
Gdx.app.postRunnable(() -> {
|
||||
NetworkManager.getInstance().receiveMessage(message);
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Server", "玩家通信异常: " + e.getMessage(), e);
|
||||
} finally {
|
||||
socket.dispose();
|
||||
connectedSockets.remove(socket);
|
||||
Gdx.app.log("Server", "玩家断开连接");
|
||||
}
|
||||
}
|
||||
|
||||
public void broadcastToOthers(Socket sender, String message) {
|
||||
for (Socket socket : connectedSockets) {
|
||||
if (socket != sender) {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(message.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
Gdx.app.log("Server", "广播消息到 " + socket.getRemoteAddress() + ": " + message);
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Server", "广播失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
for (Socket socket : connectedSockets) {
|
||||
socket.dispose();
|
||||
}
|
||||
connectedSockets.clear();
|
||||
|
||||
if (serverSocket != null) {
|
||||
serverSocket.dispose();
|
||||
serverSocket = null;
|
||||
}
|
||||
|
||||
Gdx.app.log("Server", "服务器已关闭");
|
||||
}
|
||||
}
|
||||
269
src/main/java/uno/mloluyu/network/NetworkManager.java
Normal file
@@ -0,0 +1,269 @@
|
||||
package uno.mloluyu.network;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class NetworkManager {
|
||||
private static NetworkManager instance;
|
||||
private ConnectServer server;
|
||||
private ConnectClient client;
|
||||
private boolean isHost = false;
|
||||
private String localPlayerId;
|
||||
private String localCharacter;
|
||||
private final Map<String, float[]> playerPositions = new HashMap<>();
|
||||
private final Map<String, String> playerCharacters = new HashMap<>();
|
||||
// 存储远程玩家的攻击类型(attackType)
|
||||
private final Map<String, String> playerAttacks = new HashMap<>();
|
||||
// 攻击方向:playerId -> "R" 或 "L"
|
||||
private final Map<String, String> playerAttackDirs = new HashMap<>();
|
||||
// 伤害事件:targetId -> 累积伤害(本帧内可能多次)
|
||||
private final Map<String, Integer> damageEvents = new HashMap<>();
|
||||
// 伤害方向:targetId -> dirSign (-1 / 0 / 1)
|
||||
private final Map<String, Integer> damageDirs = new HashMap<>();
|
||||
private final Map<String, float[]> respawnEvents = new HashMap<>();
|
||||
|
||||
public static NetworkManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new NetworkManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void setLocalPlayerId(String id) {
|
||||
this.localPlayerId = id;
|
||||
}
|
||||
|
||||
public String getLocalPlayerId() {
|
||||
return localPlayerId;
|
||||
}
|
||||
|
||||
public void createRoom() {// 创建房间
|
||||
isHost = true;
|
||||
server = new ConnectServer(11455);
|
||||
new Thread(server).start();
|
||||
Gdx.app.log("Network", "房主模式:服务器已启动");
|
||||
}
|
||||
|
||||
public void joinRoom(String ip) {// 加入房间
|
||||
isHost = false;
|
||||
client = new ConnectClient(ip, 11455);
|
||||
Gdx.app.log("Network", "客户端模式:连接到房主 " + ip);
|
||||
}
|
||||
|
||||
public void sendPosition(float x, float y) {// 发送位置消息
|
||||
String msg = "POS:" + localPlayerId + "," + x + "," + y;
|
||||
if (isHost && server != null) {
|
||||
server.broadcastToOthers(null, msg);
|
||||
receiveMessage(msg);
|
||||
} else if (client != null) {
|
||||
client.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendCharacterSelection(String character) {// 发送角色选择消息
|
||||
this.localCharacter = character;
|
||||
String msg = "SELECT:" + localPlayerId + "," + character;
|
||||
if (isHost && server != null) {
|
||||
server.broadcastToOthers(null, msg);
|
||||
receiveMessage(msg);
|
||||
} else if (client != null) {
|
||||
client.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地玩家所选角色(仅本地缓存,远程映射存于 playerCharacters)。
|
||||
* 供界面或后续同步逻辑查询。
|
||||
*/
|
||||
public String getLocalCharacter() {
|
||||
return localCharacter;
|
||||
}
|
||||
|
||||
public void receiveMessage(String message) {// 解析消息
|
||||
|
||||
if (message.startsWith("POS:")) {
|
||||
String[] parts = message.substring(4).split(",");
|
||||
if (parts.length == 3) {
|
||||
String playerId = parts[0];
|
||||
try {
|
||||
float x = Float.parseFloat(parts[1]);
|
||||
float y = Float.parseFloat(parts[2]);
|
||||
playerPositions.put(playerId, new float[] { x, y });
|
||||
} catch (NumberFormatException e) {
|
||||
Gdx.app.error("Network", "位置解析失败: " + message);
|
||||
}
|
||||
} else {
|
||||
Gdx.app.error("Network", "位置消息格式错误: " + message);
|
||||
}
|
||||
} else if (message.startsWith("SELECT:")) {
|
||||
String[] parts = message.substring(7).split(",");
|
||||
if (parts.length == 2) {
|
||||
String playerId = parts[0];
|
||||
String character = parts[1];
|
||||
playerCharacters.put(playerId, character);
|
||||
Gdx.app.log("Network", "角色选择: " + playerId + " -> " + character);
|
||||
} else {
|
||||
Gdx.app.error("Network", "角色选择消息格式错误: " + message);
|
||||
}
|
||||
} else if (message.equals("READY")) {
|
||||
Gdx.app.log("Network", "收到准备信号");
|
||||
} else if (message.startsWith("ATTACK:")) {
|
||||
String[] parts = message.substring(7).split(",");
|
||||
// ATTACK:playerId,attackType,dir
|
||||
if (parts.length >= 2) {
|
||||
String playerId = parts[0];
|
||||
String attackType = parts[1];
|
||||
String dir = parts.length >= 3 ? parts[2] : "R"; // 兼容旧版本无方向
|
||||
playerAttacks.put(playerId, attackType);
|
||||
playerAttackDirs.put(playerId, dir);
|
||||
Gdx.app.log("Network", "攻击同步: " + playerId + " -> " + attackType + " dir=" + dir);
|
||||
} else {
|
||||
Gdx.app.error("Network", "攻击消息格式错误: " + message);
|
||||
}
|
||||
} else if (message.startsWith("DAMAGE:")) {
|
||||
// DAMAGE:targetId,amount,(dir)
|
||||
String[] parts = message.substring(7).split(",");
|
||||
if (parts.length >= 2) {
|
||||
String targetId = parts[0];
|
||||
try {
|
||||
int amount = Integer.parseInt(parts[1]);
|
||||
damageEvents.merge(targetId, amount, Integer::sum);
|
||||
int dir = 0;
|
||||
if (parts.length >= 3) {
|
||||
try {
|
||||
dir = Integer.parseInt(parts[2]);
|
||||
} catch (NumberFormatException ignore) {
|
||||
}
|
||||
damageDirs.put(targetId, dir);
|
||||
}
|
||||
Gdx.app.log("Network", "收到伤害: " + targetId + " -" + amount + (dir != 0 ? (" dir=" + dir) : ""));
|
||||
} catch (NumberFormatException e) {
|
||||
Gdx.app.error("Network", "伤害数值解析失败: " + message);
|
||||
}
|
||||
} else {
|
||||
Gdx.app.error("Network", "伤害消息格式错误: " + message);
|
||||
}
|
||||
} else if (message.startsWith("RESPAWN:")) {
|
||||
// RESPAWN:playerId,x,y
|
||||
String[] parts = message.substring(8).split(",");
|
||||
if (parts.length == 3) {
|
||||
try {
|
||||
String pid = parts[0];
|
||||
float x = Float.parseFloat(parts[1]);
|
||||
float y = Float.parseFloat(parts[2]);
|
||||
respawnEvents.put(pid, new float[] { x, y });
|
||||
Gdx.app.log("Network", "收到重生: " + pid + " -> (" + x + "," + y + ")");
|
||||
} catch (NumberFormatException e) {
|
||||
Gdx.app.error("Network", "重生坐标解析失败: " + message);
|
||||
}
|
||||
} else {
|
||||
Gdx.app.error("Network", "重生消息格式错误: " + message);
|
||||
}
|
||||
} else {
|
||||
Gdx.app.log("Network", "未知消息类型: " + message);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, float[]> getPlayerPositions() {
|
||||
return playerPositions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程玩家的未处理攻击类型映射
|
||||
*/
|
||||
public Map<String, String> getPlayerAttacks() {
|
||||
return playerAttacks;
|
||||
}
|
||||
|
||||
public Map<String, String> getPlayerAttackDirs() {
|
||||
return playerAttackDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送攻击消息给其他玩家
|
||||
*/
|
||||
public void sendAttack(String attackType, String dir) {
|
||||
if (localPlayerId == null)
|
||||
return;
|
||||
String msg = "ATTACK:" + localPlayerId + "," + attackType + "," + dir; // 新格式包含方向
|
||||
Gdx.app.log("Network", "发送攻击消息: " + msg);
|
||||
if (isHost && server != null) {
|
||||
server.broadcastToOthers(null, msg);
|
||||
receiveMessage(msg);
|
||||
} else if (client != null) {
|
||||
client.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧代码(若仍有地方调用)默认使用 R
|
||||
public void sendAttack(String attackType) {
|
||||
sendAttack(attackType, "R");
|
||||
}
|
||||
|
||||
public Map<String, String> getPlayerCharacters() {
|
||||
return playerCharacters;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getDamageEvents() {
|
||||
return damageEvents;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getDamageDirs() {
|
||||
return damageDirs;
|
||||
}
|
||||
|
||||
public void sendDamage(String targetId, int amount, int dirSign) {
|
||||
String msg = "DAMAGE:" + targetId + "," + amount + "," + dirSign; // 含方向
|
||||
if (isHost && server != null) {
|
||||
server.broadcastToOthers(null, msg);
|
||||
receiveMessage(msg); // 本地也应用
|
||||
} else if (client != null) {
|
||||
client.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧调用,不带方向(使用0表示沿用被击者朝向逻辑)
|
||||
public void sendDamage(String targetId, int amount) {
|
||||
sendDamage(targetId, amount, 0);
|
||||
}
|
||||
|
||||
public void sendRespawn(String playerId, float x, float y) {
|
||||
String msg = "RESPAWN:" + playerId + "," + x + "," + y;
|
||||
if (isHost && server != null) {
|
||||
server.broadcastToOthers(null, msg);
|
||||
receiveMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, float[]> getRespawnEvents() {
|
||||
return respawnEvents;
|
||||
}
|
||||
|
||||
public boolean isHost() {
|
||||
return isHost;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return server != null || client != null;
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (server != null) {
|
||||
server.dispose();
|
||||
server = null;
|
||||
}
|
||||
if (client != null) {
|
||||
client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
playerPositions.clear();
|
||||
playerCharacters.clear();
|
||||
playerAttacks.clear();
|
||||
playerAttackDirs.clear();
|
||||
damageEvents.clear();
|
||||
damageDirs.clear();
|
||||
Gdx.app.log("Network", "已断开连接");
|
||||
}
|
||||
}
|
||||
20
src/main/java/uno/mloluyu/perf/PerfMetrics.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package uno.mloluyu.perf;
|
||||
|
||||
/** 简单性能指标收集(首版)。 */
|
||||
public final class PerfMetrics {
|
||||
private static long frameCount;
|
||||
private static double accumTime;
|
||||
private static double maxFrame = 0;
|
||||
|
||||
public static void frame(double delta) {
|
||||
frameCount++;
|
||||
accumTime += delta;
|
||||
if (delta > maxFrame)
|
||||
maxFrame = delta;
|
||||
}
|
||||
|
||||
public static String summary() {
|
||||
double avg = frameCount == 0 ? 0 : accumTime / frameCount;
|
||||
return String.format("frames=%d avg=%.4f max=%.4f", frameCount, avg, maxFrame);
|
||||
}
|
||||
}
|
||||
15
src/main/java/uno/mloluyu/util/ClearScreen.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package uno.mloluyu.util;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.GL20;
|
||||
|
||||
/** 清屏工具:改为静态方法避免每帧 new 对象。 */
|
||||
public final class ClearScreen {
|
||||
private ClearScreen() {
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
Gdx.gl.glClearColor(0.3F, 0.3F, 0.5F, 1);
|
||||
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
}
|
||||
31
src/main/java/uno/mloluyu/util/Font.java
Normal file
@@ -0,0 +1,31 @@
|
||||
package uno.mloluyu.util;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.g2d.BitmapFont;
|
||||
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
|
||||
import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator.FreeTypeFontParameter;
|
||||
|
||||
public class Font {
|
||||
public static BitmapFont loadChineseFont() {
|
||||
FreeTypeFontGenerator generator = null;
|
||||
try {
|
||||
generator = new FreeTypeFontGenerator(Gdx.files.internal("FLyouzichati-Regular-2.ttf")); // 你的中文字体路径
|
||||
FreeTypeFontParameter parameter = new FreeTypeFontParameter();
|
||||
parameter.size = 48;
|
||||
parameter.color = Color.WHITE;
|
||||
parameter.borderWidth = 1;
|
||||
parameter.borderColor = Color.DARK_GRAY;
|
||||
parameter.characters = "返回主菜单确退出认角色选择了角色人游戏加入联机模式 - 等待其他玩家连接...房间创建房间联机设置开始游戏设置联网中国重新开始胜者游戏结束返回主界面abcdefghijklmnopqrstuvw暂定xyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
return generator.generateFont(parameter);
|
||||
} catch (Exception e) {
|
||||
Gdx.app.error("Font Error", "加载中文字体失败: " + e.getMessage());
|
||||
return new BitmapFont(); // 回退默认字体
|
||||
} finally {
|
||||
if (generator != null)
|
||||
generator.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
40
src/main/java/uno/mloluyu/util/GameConstants.java
Normal file
@@ -0,0 +1,40 @@
|
||||
package uno.mloluyu.util;
|
||||
|
||||
/**
|
||||
* 全局游戏常量集中放置。
|
||||
*/
|
||||
public final class GameConstants {
|
||||
/**
|
||||
* 地面(空气墙)Y 坐标。抬高后便于更好构图。
|
||||
*/
|
||||
public static final float GROUND_Y = 180f; // 可按需要再调
|
||||
|
||||
// 角色移动/物理参数(集中配置便于统一手感调节)
|
||||
// 用户要求:速度 *2,跳跃 *3(基于当前 520 / 1250)
|
||||
public static final float MOVE_SPEED = 1040f; // 520 *2 (最初 ~300)
|
||||
// 调低跳跃:更低高度 + 更短滞空:v0 ↓,同时重力 ↑
|
||||
// 说明:上升时间 t_up = v0 / g,本次取 v0=1500, g=3200 =>
|
||||
// t_up≈0.47s,总滞空≈0.94s,高度≈(v0^2)/(2g)≈351
|
||||
// 若想再更低:JUMP_SPEED 1400 + GRAVITY 3400;再更高一点:JUMP_SPEED 1600 + GRAVITY 3000。
|
||||
public static final float JUMP_SPEED = 1500f;
|
||||
public static final float GRAVITY = 3200f; // 加大重力让落地更快
|
||||
|
||||
// ====== 新增:移动手感调优参数 ======
|
||||
// 水平加速度(越大越快贴近目标速度)
|
||||
public static final float MOVE_ACCEL = 6000f;
|
||||
// 水平减速(松开方向键时朝 0 速度衰减)
|
||||
public static final float MOVE_DECEL = 8000f;
|
||||
// 空中加速度系数(降低空中水平掌控)
|
||||
public static final float AIR_ACCEL_FACTOR = 0.6f;
|
||||
|
||||
// 调试命中盒渲染缩放(=1 表示真实大小;之前放大 3.6 现在回归可控)
|
||||
// 调试盒缩放:1 = 实际大小;若想放大显示结构,可调大。
|
||||
public static final float DEBUG_BOX_SCALE = 1.0f;
|
||||
// 是否按中心放大(true 则保持角色中心位置,不会视觉漂移)
|
||||
public static final boolean DEBUG_SCALE_FROM_CENTER = true;
|
||||
|
||||
// (可选)相机或后续平衡参数也可集中放这里
|
||||
|
||||
private GameConstants() {
|
||||
}
|
||||
}
|
||||
12
src/main/java/uno/mloluyu/util/ResourcePaths.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package uno.mloluyu.util;
|
||||
|
||||
/** 统一资源常量,避免魔法字符串散落。 */
|
||||
public final class ResourcePaths {
|
||||
private ResourcePaths() {
|
||||
}
|
||||
|
||||
public static final String LOGO = "logo.png";
|
||||
public static final String FONT_MAIN = "FLyouzichati-Regular-2.ttf";
|
||||
public static final String CHARACTER_ROOT = "character/";
|
||||
public static final String UI_SKIN_JSON = "ui/uiskin.json";
|
||||
}
|
||||
19
src/main/java/uno/mloluyu/util/SimpleFormatter.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package uno.mloluyu.util;
|
||||
public class SimpleFormatter {
|
||||
/**
|
||||
* 简化版:数字补前导零
|
||||
* @param number 要格式化的数字(如1, 10)
|
||||
* @param digits 保留的位数(如3位)
|
||||
* @return 带前导零的字符串(1→"001",10→"010")
|
||||
*/
|
||||
public static String addLeadingZeros(int number, int digits) {
|
||||
return String.format("%0" + digits + "d", number);
|
||||
}
|
||||
|
||||
// 测试
|
||||
public static void main(String[] args) {
|
||||
for (int i = 1; i <= 15; i++) {
|
||||
System.out.println(addLeadingZeros(i, 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/main/java/uno/mloluyu/util/TimeStepLimiter.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package uno.mloluyu.util;
|
||||
|
||||
/** 限制delta时间,防止窗口拖拽/卡顿后出现物理跳跃。 */
|
||||
public final class TimeStepLimiter {
|
||||
private TimeStepLimiter() {
|
||||
}
|
||||
|
||||
private static final float MAX_DELTA = 1f / 30f; // 上限: 相当于最低30FPS
|
||||
|
||||
public static float clamp(float delta) {
|
||||
return delta > MAX_DELTA ? MAX_DELTA : Math.max(delta, 0f);
|
||||
}
|
||||
}
|
||||
68
src/main/java/uno/mloluyu/versatile/FighterController.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package uno.mloluyu.versatile;
|
||||
|
||||
import com.badlogic.gdx.InputAdapter;
|
||||
import com.badlogic.gdx.utils.Array;
|
||||
import uno.mloluyu.characters.SimpleFighter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class FighterController extends InputAdapter {
|
||||
private final SimpleFighter fighter;
|
||||
private final Array<Integer> pressedKeys = new Array<>();
|
||||
private final Map<Integer, Float> keyPressDuration = new HashMap<>();
|
||||
|
||||
public FighterController(SimpleFighter fighter) {
|
||||
this.fighter = fighter;
|
||||
}
|
||||
|
||||
public FighterController() {
|
||||
this.fighter = null;
|
||||
}
|
||||
|
||||
public void update(float deltaTime) {
|
||||
if (fighter == null)
|
||||
return;
|
||||
|
||||
for (int keycode : pressedKeys) {
|
||||
float currentDuration = keyPressDuration.getOrDefault(keycode, 0f);
|
||||
currentDuration += deltaTime;
|
||||
keyPressDuration.put(keycode, currentDuration);
|
||||
fighter.handleInput(keycode, true, currentDuration); // 持续按下的键,传递持续时间
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyDown(int keycode) {
|
||||
// System.out.println("按键按下: " + keycode);
|
||||
if (fighter == null)
|
||||
return false;
|
||||
|
||||
if (!pressedKeys.contains(keycode, false)) {
|
||||
pressedKeys.add(keycode);
|
||||
keyPressDuration.put(keycode, 0f); // 初始化按键持续时间
|
||||
}
|
||||
|
||||
fighter.handleInput(keycode, true, 0f); // 按下事件,初始持续时间为 0
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyUp(int keycode) {
|
||||
// System.out.println("按键松开: " + keycode);
|
||||
|
||||
if (fighter == null)
|
||||
return false;
|
||||
|
||||
float duration = keyPressDuration.getOrDefault(keycode, 0f);
|
||||
pressedKeys.removeValue(keycode, false);
|
||||
keyPressDuration.remove(keycode);
|
||||
|
||||
fighter.handleInput(keycode, false, duration); // 按键松开事件,传递持续时间
|
||||
return true;
|
||||
}// 松开事件
|
||||
|
||||
public SimpleFighter getFighter() {
|
||||
return fighter;
|
||||
}
|
||||
}
|
||||
BIN
src/main/resources/FLyouzichati-Regular-2.ttf
Normal file
|
Before Width: | Height: | Size: 7.1 MiB |
BIN
src/main/resources/bg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1400
src/main/resources/character/alice/alice.atlas
Normal file
BIN
src/main/resources/character/alice/alice.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/main/resources/character/reimu/reimu-0.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/main/resources/character/reimu/reimu-1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
src/main/resources/character/reimu/reimu-2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
2008
src/main/resources/character/reimu/reimu.atlas
Normal file
BIN
src/main/resources/innerbg.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
src/main/resources/logo.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/main/resources/selectpage/10b_back_blue2p.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
src/main/resources/selectpage/11b_back_red1p.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
src/main/resources/selectpage/back_door.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
src/main/resources/selectpage/character_00.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
BIN
src/main/resources/selectpage/character_03.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
24
src/main/resources/ui/uiskin.atlas
Normal file
@@ -0,0 +1,24 @@
|
||||
uiskin.png
|
||||
format: RGBA8888
|
||||
filter: Nearest,Nearest
|
||||
repeat: none
|
||||
button-up
|
||||
rotate: false
|
||||
xy: 0, 0
|
||||
size: 200, 60
|
||||
split: 10, 10, 10, 10
|
||||
orig: 200, 60
|
||||
offset: 0, 0
|
||||
button-down
|
||||
rotate: false
|
||||
xy: 0, 60
|
||||
size: 200, 60
|
||||
split: 10, 10, 10, 10
|
||||
orig: 200, 60
|
||||
offset: 0, 0
|
||||
white
|
||||
rotate: false
|
||||
xy: 0, 120
|
||||
size: 1, 1
|
||||
orig: 1, 1
|
||||
offset: 0, 0
|
||||
46
src/main/resources/ui/uiskin.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"com.badlogic.gdx.graphics.g2d.BitmapFont": {
|
||||
"default-font": {
|
||||
"file": "default.fnt"
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.graphics.Color": {
|
||||
"black": {
|
||||
"r": 0,
|
||||
"g": 0,
|
||||
"b": 0,
|
||||
"a": 1
|
||||
},
|
||||
"white": {
|
||||
"r": 1,
|
||||
"g": 1,
|
||||
"b": 1,
|
||||
"a": 1
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.Skin$TintedDrawable": {
|
||||
"dialogDim": {
|
||||
"name": "white",
|
||||
"color": {
|
||||
"r": 0,
|
||||
"g": 0,
|
||||
"b": 0,
|
||||
"a": 0.45
|
||||
}
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle": {
|
||||
"default": {
|
||||
"up": "button-up",
|
||||
"down": "button-down"
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.TextButton$TextButtonStyle": {
|
||||
"default": {
|
||||
"up": "button-up",
|
||||
"down": "button-down",
|
||||
"font": "default-font",
|
||||
"fontColor": "black"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/test/java/uno/mloluyu/assets/AssetsExistenceTest.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package uno.mloluyu.assets;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public class AssetsExistenceTest {
|
||||
private static final Path RES = Path.of("src", "main", "resources");
|
||||
|
||||
@Test
|
||||
void testCoreAssetsPresent() {
|
||||
List<String> required = List.of("logo.png", "character/alice/alice.png", "character/reimu/reimu-0.png",
|
||||
"ui/uiskin.json");
|
||||
for (String r : required) {
|
||||
Path p = RES.resolve(r);
|
||||
Assertions.assertTrue(Files.exists(p), "缺失资源: " + r);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/test/java/uno/mloluyu/characters/ActionStateTest.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package uno.mloluyu.characters;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ActionStateTest {
|
||||
@Test
|
||||
void testLegalTransitionIdleToMove() {
|
||||
SimpleFighter f = new SimpleFighter("Test");
|
||||
f.changeAction(Action.MOVE);
|
||||
Assertions.assertEquals(Action.MOVE, f.getCurrentAction());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIllegalTransitionDeadToMove() {
|
||||
SimpleFighter f = new SimpleFighter("Test");
|
||||
f.changeAction(Action.DEAD);
|
||||
f.changeAction(Action.MOVE); // 应被拒绝
|
||||
Assertions.assertEquals(Action.DEAD, f.getCurrentAction());
|
||||
}
|
||||
}
|
||||
BIN
target/classes/FLyouzichati-Regular-2.ttf
Normal file
BIN
target/classes/bg.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1400
target/classes/character/alice/alice.atlas
Normal file
BIN
target/classes/character/alice/alice.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
target/classes/character/reimu/reimu-0.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
target/classes/character/reimu/reimu-1.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
target/classes/character/reimu/reimu-2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
2008
target/classes/character/reimu/reimu.atlas
Normal file
BIN
target/classes/innerbg.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
target/classes/logo.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
target/classes/selectpage/10b_back_blue2p.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
target/classes/selectpage/11b_back_red1p.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
target/classes/selectpage/back_door.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
target/classes/selectpage/character_00.png
Normal file
|
After Width: | Height: | Size: 576 KiB |
BIN
target/classes/selectpage/character_03.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
24
target/classes/ui/uiskin.atlas
Normal file
@@ -0,0 +1,24 @@
|
||||
uiskin.png
|
||||
format: RGBA8888
|
||||
filter: Nearest,Nearest
|
||||
repeat: none
|
||||
button-up
|
||||
rotate: false
|
||||
xy: 0, 0
|
||||
size: 200, 60
|
||||
split: 10, 10, 10, 10
|
||||
orig: 200, 60
|
||||
offset: 0, 0
|
||||
button-down
|
||||
rotate: false
|
||||
xy: 0, 60
|
||||
size: 200, 60
|
||||
split: 10, 10, 10, 10
|
||||
orig: 200, 60
|
||||
offset: 0, 0
|
||||
white
|
||||
rotate: false
|
||||
xy: 0, 120
|
||||
size: 1, 1
|
||||
orig: 1, 1
|
||||
offset: 0, 0
|
||||
46
target/classes/ui/uiskin.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"com.badlogic.gdx.graphics.g2d.BitmapFont": {
|
||||
"default-font": {
|
||||
"file": "default.fnt"
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.graphics.Color": {
|
||||
"black": {
|
||||
"r": 0,
|
||||
"g": 0,
|
||||
"b": 0,
|
||||
"a": 1
|
||||
},
|
||||
"white": {
|
||||
"r": 1,
|
||||
"g": 1,
|
||||
"b": 1,
|
||||
"a": 1
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.Skin$TintedDrawable": {
|
||||
"dialogDim": {
|
||||
"name": "white",
|
||||
"color": {
|
||||
"r": 0,
|
||||
"g": 0,
|
||||
"b": 0,
|
||||
"a": 0.45
|
||||
}
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle": {
|
||||
"default": {
|
||||
"up": "button-up",
|
||||
"down": "button-down"
|
||||
}
|
||||
},
|
||||
"com.badlogic.gdx.scenes.scene2d.ui.TextButton$TextButtonStyle": {
|
||||
"default": {
|
||||
"up": "button-up",
|
||||
"down": "button-down",
|
||||
"font": "default-font",
|
||||
"fontColor": "black"
|
||||
}
|
||||
}
|
||||
}
|
||||