feat: Scene API

This commit is contained in:
2025-12-17 13:30:55 +01:00
parent 2bca24d6cd
commit 88403993fe
19 changed files with 527 additions and 68 deletions

12
pom.xml
View File

@@ -139,5 +139,17 @@
<artifactId>lanterna</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.5.10</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg-platform</artifactId>
<version>6.1.1-1.5.10</version>
</dependency>
</dependencies>
</project>

View File

@@ -1,6 +1,6 @@
package cz.jzitnik;
import cz.jzitnik.game.GameSetup;
import cz.jzitnik.game.setup.GameSetup;
import cz.jzitnik.utils.DependencyManager;
import org.reflections.Reflections;

View File

@@ -0,0 +1,11 @@
package cz.jzitnik.events;
import cz.jzitnik.utils.events.Event;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class PlayVideo implements Event {
private String fileName;
}

View File

@@ -0,0 +1,48 @@
package cz.jzitnik.events.handlers;
import com.googlecode.lanterna.TextColor;
import com.googlecode.lanterna.screen.Screen;
import com.googlecode.lanterna.screen.TerminalScreen;
import cz.jzitnik.annotations.EventHandler;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.events.PlayVideo;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.states.TerminalState;
import cz.jzitnik.utils.DependencyManager;
import cz.jzitnik.utils.events.AbstractEventHandler;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.Java2DFrameConverter;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
@EventHandler(PlayVideo.class)
public class PlayVideoHandler extends AbstractEventHandler<PlayVideo> {
public PlayVideoHandler(DependencyManager dm) {
super(dm);
}
@InjectDependency
private ResourceManager resourceManager;
@InjectState
private TerminalState terminalState;
@Override
public void handle(PlayVideo event) {
}
private static TextColor.RGB toColor(int rgb) {
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return new TextColor.RGB(r, g, b);
}
}

View File

@@ -5,6 +5,7 @@ import cz.jzitnik.annotations.EventHandler;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.events.FullRoomDraw;
import cz.jzitnik.events.PlayVideo;
import cz.jzitnik.events.TerminalResizeEvent;
import cz.jzitnik.game.GameState;
import cz.jzitnik.states.ScreenBuffer;

View File

@@ -3,16 +3,34 @@ package cz.jzitnik.game;
import cz.jzitnik.annotations.State;
import cz.jzitnik.game.objects.Interactable;
import cz.jzitnik.screens.Screen;
import cz.jzitnik.utils.DependencyManager;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@RequiredArgsConstructor
@State
@Getter
@Setter
public class GameState {
private final DependencyManager dependencyManager;
@Getter
@Setter
private GameRoom currentRoom;
@Getter
@Setter
private Player player;
@Getter
@Setter
private Interactable interacting;
@Getter
private Screen screen;
public void setScreen(Screen screen) {
if (screen != null) {
dependencyManager.inject(screen);
}
this.screen = screen;
}
}

View File

@@ -59,4 +59,8 @@ public class ResourceManager {
throw new RuntimeException(e);
}
}
public InputStream getResourceAsStream(String path) {
return classLoader.getResourceAsStream(path);
}
}

View File

@@ -1,12 +1,17 @@
package cz.jzitnik.game;
package cz.jzitnik.game.setup;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.game.GameRoom;
import cz.jzitnik.game.GameState;
import cz.jzitnik.game.Player;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.game.items.GameItem;
import cz.jzitnik.game.items.WoodenSword;
import cz.jzitnik.game.objects.Chest;
import cz.jzitnik.game.utils.RoomCords;
import cz.jzitnik.screens.scenes.IntroScene;
import cz.jzitnik.utils.DependencyManager;
@Dependency
@@ -21,6 +26,8 @@ public class GameSetup {
private DependencyManager dependencyManager;
public void setup() {
gameState.setScreen(new IntroScene(dependencyManager));
GameRoom mainRoom = new GameRoom(ResourceManager.Resource.ROOM1);
GameRoom rightRoom = new GameRoom(ResourceManager.Resource.ROOM2);
GameRoom topRightRoom = new GameRoom(ResourceManager.Resource.ROOM3);

View File

@@ -0,0 +1,152 @@
package cz.jzitnik.screens;
import com.googlecode.lanterna.TextColor;
import com.googlecode.lanterna.screen.TerminalScreen;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.game.ResourceManager;
import cz.jzitnik.states.TerminalState;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.Java2DFrameConverter;
import com.googlecode.lanterna.screen.Screen.RefreshType;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
@Slf4j
public abstract class VideoPlayScreen extends Screen {
@InjectDependency
private ResourceManager resourceManager;
@InjectState
private TerminalState terminalState;
private final String videoPath;
protected boolean isRenderedAlready;
public VideoPlayScreen(String videoPath) {
this.videoPath = videoPath;
}
@Override
public void fullRender() {
if (!isRenderedAlready) {
isRenderedAlready = true;
render();
}
}
protected void render() {
File tempVideo = null;
try (InputStream resource = resourceManager.getResourceAsStream(videoPath)) {
TerminalScreen screen = terminalState.getTerminalScreen();
var tg = terminalState.getTextGraphics();
tempVideo = File.createTempFile("lanterna-video", ".mp4");
tempVideo.deleteOnExit();
Files.copy(resource, tempVideo.toPath(), StandardCopyOption.REPLACE_EXISTING);
try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(tempVideo);
Java2DFrameConverter converter = new Java2DFrameConverter()) {
grabber.start();
double fps = grabber.getFrameRate();
long frameDelayNs = (long) (1_000_000_000 / fps);
long videoStartTime = System.nanoTime();
long frameIndex = 0;
// Track previous terminal size
int prevTermWidth = screen.getTerminalSize().getColumns();
int prevTermHeight = screen.getTerminalSize().getRows();
Frame frame;
while ((frame = grabber.grabImage()) != null) {
long now = System.nanoTime();
long expectedTime = videoStartTime + frameIndex * frameDelayNs;
// Skip frames if we're behind schedule
while (now > expectedTime) {
frame = grabber.grabImage();
if (frame == null) break;
frameIndex++;
expectedTime = videoStartTime + frameIndex * frameDelayNs;
now = System.nanoTime();
}
if (frame == null) break;
BufferedImage img = converter.convert(frame);
if (img == null) continue;
int termWidth = screen.getTerminalSize().getColumns();
int termHeight = screen.getTerminalSize().getRows();
int targetWidth = termWidth;
int targetHeight = termHeight * 2;
BufferedImage scaledImg = img;
if (img.getWidth() != targetWidth || img.getHeight() != targetHeight) {
scaledImg = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
scaledImg.getGraphics().drawImage(img, 0, 0, targetWidth, targetHeight, null);
}
for (int y = 0; y < termHeight; y++) {
for (int x = 0; x < termWidth; x++) {
int topPixel = scaledImg.getRGB(x, y * 2);
int bottomPixel = scaledImg.getRGB(x, y * 2 + 1);
TextColor.RGB bg = toColor(topPixel);
TextColor.RGB fg = toColor(bottomPixel);
tg.setBackgroundColor(bg);
tg.setForegroundColor(fg);
tg.setCharacter(x, y, '▄');
}
}
RefreshType refreshType;
if (termWidth != prevTermWidth || termHeight != prevTermHeight) {
refreshType = RefreshType.COMPLETE;
prevTermWidth = termWidth;
prevTermHeight = termHeight;
} else {
refreshType = RefreshType.DELTA;
}
screen.refresh(refreshType);
frameIndex++;
long nextFrameTime = videoStartTime + frameIndex * frameDelayNs;
long sleepTime = nextFrameTime - System.nanoTime();
if (sleepTime > 0) {
Thread.sleep(sleepTime / 1_000_000, (int) (sleepTime % 1_000_000));
}
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to play video", e);
} finally {
if (tempVideo != null && tempVideo.exists()) {
if (!tempVideo.delete()) {
System.err.println("Warning: failed to delete temp video file " + tempVideo.getAbsolutePath());
}
}
}
}
private TextColor.RGB toColor(int rgb) {
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return new TextColor.RGB(r, g, b);
}
}

View File

@@ -0,0 +1,19 @@
package cz.jzitnik.screens.scenes;
import cz.jzitnik.events.KeyboardPressEvent;
import cz.jzitnik.events.MouseAction;
import cz.jzitnik.screens.VideoPlayScreen;
public final class BasicVideoScene extends VideoPlayScreen {
public BasicVideoScene(String videoPath) {
super(videoPath);
}
@Override
public void handleMouseAction(MouseAction event) {
}
@Override
public void handleKeyboardAction(KeyboardPressEvent event) {
}
}

View File

@@ -0,0 +1,16 @@
package cz.jzitnik.screens.scenes;
import cz.jzitnik.screens.Screen;
import cz.jzitnik.utils.DependencyManager;
public class IntroScene extends Scene {
public IntroScene(DependencyManager dependencyManager) {
super(
new Screen[]{
new VideoSceneWithAudio("video.mp4", "audio.ogg")
},
OnEndAction.SWITCH_TO_GAME
);
dependencyManager.inject(this);
}
}

View File

@@ -0,0 +1,76 @@
package cz.jzitnik.screens.scenes;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.events.FullRoomDraw;
import cz.jzitnik.events.KeyboardPressEvent;
import cz.jzitnik.events.MouseAction;
import cz.jzitnik.game.GameState;
import cz.jzitnik.screens.Screen;
import cz.jzitnik.utils.DependencyManager;
import cz.jzitnik.utils.events.EventManager;
public abstract class Scene extends Screen {
private final Screen[] parts;
private Screen currentPart;
private int currentIndex = 0;
private boolean isRenderedAlready = false;
private final OnEndAction onEndAction;
@InjectDependency
private DependencyManager dependencyManager;
@InjectState
private GameState gameState;
@InjectDependency
private EventManager eventManager;
public enum OnEndAction {
SWITCH_TO_GAME
}
public Scene(Screen[] parts, OnEndAction onEndAction) {
this.parts = parts;
this.currentPart = parts[0];
this.onEndAction = onEndAction;
}
@Override
public void fullRender() {
if (!isRenderedAlready) {
isRenderedAlready = true;
render();
}
}
private void render() {
while (currentPart != null) {
dependencyManager.inject(currentPart);
currentPart.fullRender();
try {
currentPart = parts[++currentIndex];
} catch (ArrayIndexOutOfBoundsException e) {
currentPart = null;
}
}
switch (onEndAction) {
case SWITCH_TO_GAME -> {
gameState.setScreen(null);
eventManager.emitEvent(new FullRoomDraw(true));
}
}
}
@Override
public void handleMouseAction(MouseAction event) {
currentPart.handleMouseAction(event);
}
@Override
public void handleKeyboardAction(KeyboardPressEvent event) {
currentPart.handleKeyboardAction(event);
}
}

View File

@@ -0,0 +1,42 @@
package cz.jzitnik.screens.scenes;
import cz.jzitnik.events.KeyboardPressEvent;
import cz.jzitnik.events.MouseAction;
import cz.jzitnik.screens.VideoPlayScreen;
import cz.jzitnik.sound.SoundPlayer;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class VideoSceneWithAudio extends VideoPlayScreen {
private final SoundPlayer soundPlayer = new SoundPlayer();
private final String audioPath;
public VideoSceneWithAudio(String videoPath, String audioPath) {
super(videoPath);
this.audioPath = audioPath;
}
@Override
public void fullRender() {
if (!isRenderedAlready) {
isRenderedAlready = true;
playSound();
render();
this.soundPlayer.stopCurrentSound();
}
}
private void playSound() {
new Thread(() -> soundPlayer.playSound(audioPath, 100, 100)).start();
}
@Override
public void handleMouseAction(MouseAction event) {
}
@Override
public void handleKeyboardAction(KeyboardPressEvent event) {
}
}

View File

@@ -1,76 +1,107 @@
package cz.jzitnik.sound;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.injectors.InjectDependency;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.*;
import javax.sound.sampled.*;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Dependency
public class SoundPlayer {
@InjectDependency
private ClassLoader classLoader;
private final AtomicReference<SourceDataLine> currentLine = new AtomicReference<>();
public void playSound(String filePath, int backendVolume, int masterVolume) {
long threadId = Thread.currentThread().getId();
public void playSound(String filePath, int backendVolume, int masterVolume)
throws LineUnavailableException, IOException, UnsupportedAudioFileException {
if (!filePath.endsWith(".ogg") || masterVolume == 0) {
return; // No sound if master volume is 0
}
log.info("Loading resource: {}", "sounds/" + filePath);
var file = classLoader.getResourceAsStream("sounds/" + filePath);
if (file == null) {
return;
}
AudioInputStream audioStream = AudioSystem.getAudioInputStream(file);
AudioFormat baseFormat = audioStream.getFormat();
AudioFormat targetFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
baseFormat.getSampleRate(),
16,
baseFormat.getChannels(),
baseFormat.getChannels() * 2,
baseFormat.getSampleRate(),
false
);
AudioInputStream dataIn = AudioSystem.getAudioInputStream(targetFormat, audioStream);
byte[] buffer = new byte[8192];
DataLine.Info info = new DataLine.Info(SourceDataLine.class, targetFormat);
try (SourceDataLine line = (SourceDataLine) AudioSystem.getLine(info)) {
if (line != null) {
line.open(targetFormat);
line.start();
float finalVolume = (backendVolume / 100.0f) * (masterVolume / 100.0f);
log.info("Applying volume: {} (backend: {}, master: {})", finalVolume, backendVolume, masterVolume);
int bytesRead;
while ((bytesRead = dataIn.read(buffer, 0, buffer.length)) != -1) {
applyVolume(buffer, bytesRead, finalVolume);
line.write(buffer, 0, bytesRead);
}
line.drain();
try (var fileStream = getClass().getClassLoader().getResourceAsStream(filePath)) {
if (fileStream == null) {
return;
}
}
dataIn.close();
audioStream.close();
try (AudioInputStream audioStream = AudioSystem.getAudioInputStream(fileStream)) {
AudioFormat baseFormat = audioStream.getFormat();
AudioFormat targetFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
baseFormat.getSampleRate(),
16,
baseFormat.getChannels(),
baseFormat.getChannels() * 2,
baseFormat.getSampleRate(),
false
);
try (AudioInputStream dataIn = AudioSystem.getAudioInputStream(targetFormat, audioStream)) {
DataLine.Info info = new DataLine.Info(SourceDataLine.class, targetFormat);
SourceDataLine line = (SourceDataLine) AudioSystem.getLine(info);
currentLine.set(line);
if (line != null) {
line.open(targetFormat);
if (line.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
FloatControl gainControl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN);
float finalVolume = (backendVolume / 100.0f) * (masterVolume / 100.0f);
float dB = (float) (Math.log10(Math.max(finalVolume, 0.0001f)) * 20.0f);
gainControl.setValue(dB);
}
line.start();
log.info("[{}] Line started.", threadId);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = dataIn.read(buffer, 0, buffer.length)) != -1) {
if (Thread.currentThread().isInterrupted() || !line.isOpen()) {
log.info("[{}] Loop interrupted.", threadId);
break;
}
try {
line.write(buffer, 0, bytesRead);
} catch (Exception e) {
break;
}
}
// Cleanup (only if not already closed)
if (line.isOpen()) {
line.drain();
line.stop();
line.close();
}
}
} catch (LineUnavailableException | IOException e) {
throw new RuntimeException(e);
}
} catch (UnsupportedAudioFileException e) {
throw new RuntimeException(e);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
currentLine.set(null);
}
}
private static void applyVolume(byte[] buffer, int bytesRead, float volume) {
for (int i = 0; i < bytesRead; i += 2) { // 16-bit PCM samples are 2 bytes each
int sample = (buffer[i] & 0xFF) | (buffer[i + 1] << 8);
sample = (int) (sample * volume);
buffer[i] = (byte) (sample & 0xFF);
buffer[i + 1] = (byte) ((sample >> 8) & 0xFF);
/**
* Call this method from your Main Thread / VideoSceneWithAudio
* to stop the sound INSTANTLY.
*/
public void stopCurrentSound() {
SourceDataLine line = currentLine.get();
if (line != null && line.isOpen()) {
log.info("Force stopping sound...");
line.flush();
line.stop();
line.close();
}
}
}

View File

@@ -0,0 +1,8 @@
package cz.jzitnik.states;
import cz.jzitnik.annotations.State;
@State
public class PlayerConfig {
private int masterVolume = 100;
}

View File

@@ -16,8 +16,7 @@ import org.reflections.Reflections;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Optional;
import java.util.Set;
import java.util.*;
@Slf4j
public class DependencyManager {
@@ -105,13 +104,20 @@ public class DependencyManager {
public void inject(Object instance) {
StateManager stateManager = (StateManager) data.get(StateManager.class);
Class<?> clazz = instance.getClass();
for (Field field : clazz.getDeclaredFields()) {
List<Field> allFields = new ArrayList<>();
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
allFields.addAll(Arrays.asList(current.getDeclaredFields()));
current = current.getSuperclass();
}
for (Field field : allFields) {
if (field.isAnnotationPresent(InjectDependency.class)) {
field.setAccessible(true);
if (!data.containsKey(field.getType())) continue;
if (!data.containsKey(field.getType()) && field.getType() != getClass()) continue;
Object dependency = field.getType() == getClass() ? this : data.get(field.getType());

View File

@@ -3,6 +3,7 @@ package cz.jzitnik.utils;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.State;
import cz.jzitnik.annotations.injectors.InjectDependency;
import lombok.extern.slf4j.Slf4j;
import org.reflections.Reflections;
@@ -15,15 +16,22 @@ import java.util.Optional;
public class StateManager {
private final HashMap<Class<?>, Object> data = new HashMap<>();
public StateManager(Reflections reflections) {
public StateManager(Reflections reflections, DependencyManager dependencyManager) {
var classes = reflections.getTypesAnnotatedWith(State.class);
for (Class<?> clazz : classes) {
try {
var instance = clazz.getDeclaredConstructor().newInstance();
data.put(clazz, instance);
} catch (InstantiationException | IllegalAccessException | InvocationTargetException |
NoSuchMethodException e) {
} catch (NoSuchMethodException e) {
try {
var instance = clazz.getDeclaredConstructor(DependencyManager.class).newInstance(dependencyManager);
data.put(clazz, instance);
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException |
IllegalAccessException ex) {
throw new RuntimeException(ex);
}
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
log.error("Failed to instantiate state class: {}", clazz.getName(), e);
}
}

Binary file not shown.

Binary file not shown.