feat: Started implementing screen rendering

And some minor design changes and stuff like that.
This commit is contained in:
2025-12-11 22:00:21 +01:00
parent 10a2e402b7
commit da234d9985
16 changed files with 302 additions and 44 deletions

View File

@@ -8,11 +8,16 @@ import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.injectors.InjectDependency;
import cz.jzitnik.events.KeyboardPressEvent;
import cz.jzitnik.events.MouseAction;
import cz.jzitnik.events.RerenderScreen;
import cz.jzitnik.events.TerminalResizeEvent;
import cz.jzitnik.states.RunningState;
import cz.jzitnik.utils.StateManager;
import cz.jzitnik.utils.events.EventManager;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
@Slf4j
@Dependency
public class Cli implements Runnable {
@InjectDependency
@@ -21,33 +26,38 @@ public class Cli implements Runnable {
@InjectDependency
private StateManager stateManager;
@SneakyThrows // I know deal with it
@Override
public void run() {
eventManager.start(); // Start event manager thread
TerminalScreen terminal = new DefaultTerminalFactory()
try (TerminalScreen terminal = new DefaultTerminalFactory()
.setMouseCaptureMode(MouseCaptureMode.CLICK_RELEASE_DRAG_MOVE)
.createScreen();
.createScreen()) {
stateManager.registerManually(terminal);
terminal.setCursorPosition(null);
terminal.doResizeIfNecessary();
terminal.getTerminal().enterPrivateMode();
terminal.setCursorPosition(null);
terminal.doResizeIfNecessary();
terminal.getTerminal().enterPrivateMode();
RunningState runningState = stateManager.getOrThrow(RunningState.class);
while (runningState.isRunning()) {
KeyStroke keyStroke = terminal.pollInput();
if (keyStroke != null) {
if (keyStroke instanceof com.googlecode.lanterna.input.MouseAction mouse) {
eventManager.emitEvent(new MouseAction(mouse));
continue;
terminal.getTerminal().addResizeListener((_, terminalSize) -> eventManager.emitEvent(new TerminalResizeEvent(terminalSize)));
RunningState runningState = stateManager.getOrThrow(RunningState.class);
while (runningState.isRunning()) {
KeyStroke keyStroke = terminal.readInput();
if (keyStroke != null) {
if (keyStroke instanceof com.googlecode.lanterna.input.MouseAction mouse) {
eventManager.emitEvent(new MouseAction(mouse));
continue;
}
eventManager.emitEvent(new KeyboardPressEvent(keyStroke));
}
eventManager.emitEvent(new KeyboardPressEvent(keyStroke));
}
Thread.sleep(50);
}
terminal.close();
eventManager.emitEvent(RerenderScreen.full(terminal.getTerminalSize()));
} catch (IOException e) {
log.error("Terminal error occurred, shutting down CLI thread.", e);
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,10 @@
package cz.jzitnik.config;
import cz.jzitnik.annotations.Config;
import lombok.Getter;
@Getter
@Config
public class EventThreadPoolConfig {
private final int threadCount = 8;
}

View File

@@ -0,0 +1,28 @@
package cz.jzitnik.events;
import com.googlecode.lanterna.TerminalSize;
import cz.jzitnik.utils.events.Event;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.awt.*;
public record RerenderScreen(ScreenPart[] parts) implements Event {
public static RerenderScreen full(TerminalSize terminalSize) {
return new RerenderScreen(
new ScreenPart[]{
new ScreenPart(
new Point(0, 0),
new Point(terminalSize.getRows() - 1, terminalSize.getColumns() - 1)
)
}
);
}
@Data
@AllArgsConstructor
public static class ScreenPart {
private Point start;
private Point end;
}
}

View File

@@ -0,0 +1,12 @@
package cz.jzitnik.events;
import com.googlecode.lanterna.TerminalSize;
import cz.jzitnik.utils.events.Event;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class TerminalResizeEvent implements Event {
private TerminalSize newSize;
}

View File

@@ -0,0 +1,54 @@
package cz.jzitnik.events.handlers;
import com.googlecode.lanterna.TextCharacter;
import com.googlecode.lanterna.TextColor;
import com.googlecode.lanterna.screen.TerminalScreen;
import cz.jzitnik.annotations.EventHandler;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.events.RerenderScreen;
import cz.jzitnik.states.ScreenBuffer;
import cz.jzitnik.ui.pixels.Empty;
import cz.jzitnik.ui.pixels.Pixel;
import cz.jzitnik.utils.DependencyManager;
import cz.jzitnik.utils.events.AbstractEventHandler;
import java.awt.*;
@EventHandler(RerenderScreen.class)
public class CliHandler extends AbstractEventHandler<RerenderScreen> {
@InjectState
private TerminalScreen terminalScreen;
@InjectState
private ScreenBuffer screenBuffer;
public CliHandler(DependencyManager dm) {
super(dm);
}
@Override
public void handle(RerenderScreen event) {
var parts = event.parts();
var buffer = screenBuffer.getBuffer();
for (RerenderScreen.ScreenPart part : parts) {
Point a = part.getStart();
Point b = part.getEnd();
for (double y = a.getY(); y < b.getY(); y++) {
for (double x = a.getX(); x < b.getX(); x++) {
Pixel pixel = buffer[(int) y][(int) x];
terminalScreen.setCharacter(
(int) x,
(int) y,
new TextCharacter(
' ',
TextColor.ANSI.DEFAULT,
pixel.getClass().equals(Empty.class) ? TextColor.ANSI.DEFAULT : pixel.getColor()
)
);
}
}
}
}
}

View File

@@ -24,14 +24,9 @@ public class KeyboardPressEventHandler extends AbstractEventHandler<KeyboardPres
case Escape:
eventManager.emitEvent(new ExitEvent());
break;
case ArrowUp:
System.out.println("Up arrow pressed");
break;
case Character:
System.out.println("Key pressed: " + keyStroke.getCharacter());
break;
default:
System.out.println("IDK: " + keyStroke.getKeyType());
break;
}
}

View File

@@ -13,6 +13,5 @@ public class MouseActionEventHandler extends AbstractEventHandler<MouseAction> {
@Override
public void handle(MouseAction event) {
System.out.println("ACTION: " + event);
}
}

View File

@@ -0,0 +1,18 @@
package cz.jzitnik.events.handlers;
import cz.jzitnik.annotations.EventHandler;
import cz.jzitnik.events.TerminalResizeEvent;
import cz.jzitnik.utils.DependencyManager;
import cz.jzitnik.utils.events.AbstractEventHandler;
@EventHandler(TerminalResizeEvent.class)
public class TerminalResizeEventHandler extends AbstractEventHandler<TerminalResizeEvent> {
public TerminalResizeEventHandler(DependencyManager dm) {
super(dm);
}
@Override
public void handle(TerminalResizeEvent event) {
System.out.println("NEWSIZE: " + event.getNewSize());
}
}

View File

@@ -0,0 +1,76 @@
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 java.io.IOException;
@Slf4j
@Dependency
public class SoundPlayer {
@InjectDependency
private ClassLoader classLoader;
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();
}
}
dataIn.close();
audioStream.close();
}
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);
}
}
}

View File

@@ -0,0 +1,11 @@
package cz.jzitnik.states;
import cz.jzitnik.annotations.State;
import cz.jzitnik.ui.pixels.Pixel;
import lombok.Data;
@Data
@State
public class ScreenBuffer {
private Pixel[][] buffer;
}

View File

@@ -0,0 +1,9 @@
package cz.jzitnik.ui.pixels;
import com.googlecode.lanterna.TextColor;
public final class ColoredPixel extends Pixel {
public ColoredPixel(TextColor color) {
super(color);
}
}

View File

@@ -0,0 +1,7 @@
package cz.jzitnik.ui.pixels;
public final class Empty extends Pixel {
public Empty() {
super(null);
}
}

View File

@@ -0,0 +1,11 @@
package cz.jzitnik.ui.pixels;
import com.googlecode.lanterna.TextColor;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public sealed abstract class Pixel permits Empty, ColoredPixel {
protected TextColor color;
}

View File

@@ -1,5 +1,8 @@
package cz.jzitnik.utils;
// Don't blame me im using field injection instead of construction injection. I just like it more leave me alone.
// Yes I know I'll suffer in the unit tests.
import cz.jzitnik.annotations.Config;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.injectors.InjectConfig;
@@ -47,10 +50,12 @@ public class DependencyManager {
var instance = constructor.newInstance();
configs.put(configClass, instance);
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException |
IllegalAccessException _) {
IllegalAccessException e) {
log.error("Failed to instantiate config class: {}", configClass.getName(), e);
}
}
data.put(ClassLoader.class, DependencyManager.class.getClassLoader());
Set<Class<?>> classes = reflections.getTypesAnnotatedWith(Dependency.class);
// Construct all classes
@@ -62,9 +67,7 @@ public class DependencyManager {
for (int i = 0; i < paramTypes.length; i++) {
Class<?> type = paramTypes[i];
if (configs.containsKey(type))
params[i] = configs.get(type);
else if (type == getClass())
if (type == getClass())
params[i] = this;
else if (type == Reflections.class)
params[i] = reflections;
@@ -103,7 +106,7 @@ public class DependencyManager {
if (!data.containsKey(field.getType())) continue;
Object dependency = data.get(field.getType());
Object dependency = field.getType() == getClass() ? this : data.get(field.getType());
if (!field.getType().isAssignableFrom(dependency.getClass())) continue;

View File

@@ -3,12 +3,14 @@ package cz.jzitnik.utils;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.State;
import lombok.extern.slf4j.Slf4j;
import org.reflections.Reflections;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Optional;
@Slf4j
@Dependency
public class StateManager {
private final HashMap<Class<?>, Object> data = new HashMap<>();
@@ -21,11 +23,16 @@ public class StateManager {
var instance = clazz.getDeclaredConstructor().newInstance();
data.put(clazz, instance);
} catch (InstantiationException | IllegalAccessException | InvocationTargetException |
NoSuchMethodException _) {
NoSuchMethodException e) {
log.error("Failed to instantiate state class: {}", clazz.getName(), e);
}
}
}
public void registerManually(Object instance) {
data.put(instance.getClass(), instance);
}
@SuppressWarnings("unchecked")
public <T> Optional<T> get(Class<T> clazz) {
return Optional.ofNullable((T) data.get(clazz));

View File

@@ -2,21 +2,28 @@ package cz.jzitnik.utils.events;
import cz.jzitnik.annotations.Dependency;
import cz.jzitnik.annotations.EventHandler;
import cz.jzitnik.annotations.injectors.InjectConfig;
import cz.jzitnik.annotations.injectors.InjectState;
import cz.jzitnik.config.EventThreadPoolConfig;
import cz.jzitnik.states.RunningState;
import cz.jzitnik.utils.DependencyManager;
import lombok.extern.slf4j.Slf4j;
import org.reflections.Reflections;
import org.w3c.dom.events.EventException;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.*;
@Slf4j
@Dependency
public class EventManager extends Thread {
@InjectState
private RunningState runningState;
@InjectConfig
private EventThreadPoolConfig eventThreadPoolConfig;
private ExecutorService eventExecutor;
private final HashMap<Class<? extends Event>, AbstractEventHandler<? extends Event>> handlers = new HashMap<>();
private final BlockingQueue<Event> eventQueue = new LinkedBlockingQueue<>();
@@ -29,6 +36,7 @@ public class EventManager extends Thread {
eventQueue.add(event);
}
@SuppressWarnings("unchecked")
public EventManager(Reflections reflections, DependencyManager dependencyManager) {
setDaemon(true);
@@ -41,34 +49,34 @@ public class EventManager extends Thread {
handlers.put(eventHandler.value(), instance);
} catch (InstantiationException | IllegalAccessException | InvocationTargetException |
NoSuchMethodException e) {
log.error("Failed to instantiate event handler: {}", clazz.getName(), e);
}
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public void run() {
eventExecutor = Executors.newFixedThreadPool(eventThreadPoolConfig.getThreadCount());
while (runningState.isRunning()) {
try {
Event event = eventQueue.take();
handleEvent(event);
} catch (InterruptedException e) {
// Program stops
// The game is shutting down.
eventExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
eventExecutor.shutdown();
}
@SuppressWarnings("unchecked")
private <T extends Event> void handleEvent(Event event) {
T typedEvent = (T) event; // safe because handler is keyed by event.getClass()
T typedEvent = (T) event;
AbstractEventHandler<T> handler = getHandler((Class<T>) event.getClass());
Thread thread = new Thread(() -> handler.handle(typedEvent));
thread.setDaemon(true);
thread.start();
if (handler != null) {
eventExecutor.submit(() -> handler.handle(typedEvent));
}
}
}