1
0
Fork 0
mirror of https://github.com/imjasonh/terminus synced 2026-07-06 22:12:35 +00:00

initial commit

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-09-23 20:52:35 -04:00
commit 71f0463b73
6 changed files with 215 additions and 0 deletions

BIN
example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module image-to-ansi
go 1.25.1
require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=

81
main.go Normal file
View file

@ -0,0 +1,81 @@
package main
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"log"
"os"
"strconv"
"strings"
"github.com/nfnt/resize"
)
func convertImageToAnsi(img image.Image, width int) string {
bounds := img.Bounds()
origWidth := bounds.Dx()
origHeight := bounds.Dy()
aspectRatio := float64(origHeight) / float64(origWidth)
height := int(float64(width) * aspectRatio)
resizedImg := resize.Resize(uint(width), uint(height), img, resize.Lanczos3)
var builder strings.Builder
resizedBounds := resizedImg.Bounds()
for y := resizedBounds.Min.Y; y < resizedBounds.Max.Y; y += 2 {
for x := resizedBounds.Min.X; x < resizedBounds.Max.X; x++ {
r1, g1, b1, _ := resizedImg.At(x, y).RGBA()
r1_8, g1_8, b1_8 := uint8(r1>>8), uint8(g1>>8), uint8(b1>>8)
r2, g2, b2 := r1_8, g1_8, b1_8
if y+1 < resizedBounds.Max.Y {
r2_raw, g2_raw, b2_raw, _ := resizedImg.At(x, y+1).RGBA()
r2, g2, b2 = uint8(r2_raw>>8), uint8(g2_raw>>8), uint8(b2_raw>>8)
}
ansiString := fmt.Sprintf("\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀", r1_8, g1_8, b1_8, r2, g2, b2)
builder.WriteString(ansiString)
}
builder.WriteString("\x1b[0m\n")
}
builder.WriteString("\x1b[0m")
return builder.String()
}
func main() {
if len(os.Args) < 2 {
log.Fatal("Usage: go run main.go <image_path> [width]")
}
filePath := os.Args[1]
outputWidth := 80
if len(os.Args) > 2 {
var err error
outputWidth, err = strconv.Atoi(os.Args[2])
if err != nil {
log.Fatalf("Invalid width: %v", err)
}
}
file, err := os.Open(filePath)
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
log.Fatalf("Failed to decode image: %v", err)
}
ansiArt := convertImageToAnsi(img, outputWidth)
fmt.Print(ansiArt)
}

127
plan.md Normal file
View file

@ -0,0 +1,127 @@
Of course. This is a fantastic and ambitious project. Building a Wolfenstein 3D-style engine that renders to the terminal and is served over SSH is a classic creative coding challenge. The core of this project will be a **raycasting engine**, which is the technique used by Wolfenstein 3D to create a 3D illusion from a 2D map.
Here is a detailed, multi-phased plan designed to be implemented by an AI coding agent.
---
### **Project: "Terminus"—A Go-Based SSH Terminal FPS Engine**
**Objective:** Create a multiplayer-ready, server-side application in Go that runs a 3D first-person shooter. Players connect via a standard SSH client, and the game world is rendered in real-time using colored characters in their terminal.
**Core Concepts:**
1. **Raycasting:** We will not be rendering a true 3D world with polygons. Instead, for each column of the terminal screen, we'll cast a single mathematical "ray" from the player's position. We calculate where this ray intersects with a wall on our 2D map. The distance to that wall determines how tall the wall slice is drawn in that column. This is computationally very cheap and perfect for this application.
2. **Server-Side Game Loop:** The entire game state (player positions, map, etc.) and the rendering logic will live on the server. The server will run a continuous loop for each connected player: process their input, update their state, render their view, and send the resulting string of characters and ANSI codes back to their SSH client.
3. **ANSI Escape Codes:** The "graphics" will be achieved by sending special text sequences to the terminal. We will use these codes to:
* Set foreground and background colors (`\x1b[38;2;...m` and `\x1b[48;2;...m`).
* Position the cursor (`\x1b[H` to move to the top-left) to redraw the screen without clearing it, which reduces flicker.
* Use Unicode block characters (`█`, `▓`, `▒`, `░`, `▀`, `▄`) for shading and texturing.
---
### **Technology Stack & Dependencies**
* **Go Standard Library:** `fmt`, `log`, `math`, `os`, `strings`, `time`.
* **SSH Server:** `github.com/gliderlabs/ssh` - A flexible and easy-to-use library for creating a full-featured SSH server.
* **Terminal/TTY Manipulation:** `github.com/creack/pty` and `golang.org/x/term` - Necessary for handling the interactive terminal session over SSH, including reading raw key presses and getting the terminal dimensions.
---
### **Phase 1: The Core Engine (Single-Player, Local)**
**Goal:** Create the fundamental data structures and the raycasting renderer. At the end of this phase, you should be able to run the program locally and walk around a hardcoded map.
**Step 1: Data Structures**
* Create a `game/` directory for your core logic.
* **`vector.go`**: Define a `Vector` struct with `X` and `Y` `float64` fields. This will be used for positions, directions, etc.
* **`player.go`**: Define a `Player` struct. It must contain:
* `Position Vector` (current x, y coordinates).
* `Direction Vector` (the direction the player is facing).
* `CameraPlane Vector` (perpendicular to the direction, this defines the field of view).
* **`world.go`**: Define a `Map` struct containing a 2D slice of integers (e.g., `[][]int`). `0` will represent an empty space, and `1` or greater will represent different types of walls. Hardcode a simple map for now.
**Step 2: The Raycasting Renderer**
* Create a `renderer/` directory.
* **`renderer.go`**: This is the most complex part. Create a `Render` function that takes the `Player` and `Map` as input.
* **The Raycasting Loop:**
1. The function will loop from `x = 0` to `screenWidth - 1` (where `screenWidth` is the width of your terminal).
2. **Calculate Ray Direction:** For each `x`, calculate the direction of the ray. This starts from the player's direction and is adjusted based on `x` and the camera plane.
3. **DDA Algorithm (Digital Differential Analysis):** Implement a DDA loop to step the ray through the map grid until it hits a non-zero tile. You will need to keep track of:
* `mapX`, `mapY`: The current grid square the ray is in.
* `sideDistX`, `sideDistY`: Distance from the ray's start to the next grid line.
* `deltaDistX`, `deltaDistY`: Distance the ray travels to cross one full grid square.
4. **Calculate Wall Distance:** Once a wall is hit, calculate the perpendicular distance from the player to the wall. This is crucial to prevent a "fisheye" lens effect.
5. **Calculate Wall Height:** Use the distance to calculate the height of the wall slice to draw in column `x`. Closer walls should be taller. `lineHeight = screenHeight / perpendicularWallDistance`.
6. **Determine Shading/Texture:** Based on whether the ray hit a vertical or horizontal side of a map grid, choose a different character or color. For example, use `█` for N-S walls and `▓` for E-W walls.
7. **Draw the Column:** For the calculated `lineHeight`, draw the shaded wall character. Above and below it, draw ceiling and floor characters (e.g., spaces with a background color or `-` and `.` characters).
**Step 3: The Screen Buffer & Main Loop**
* Create a `screen/` directory.
* **`screen.go`**: Define a `Screen` buffer, which is a 2D slice of `structs`. Each struct will hold `{Char rune, FgColor color.Color, BgColor color.Color}`.
* In `main.go`, create the main game loop:
1. Initialize the player, map, and screen buffer.
2. Start a loop that runs at a fixed rate (e.g., 30 times per second).
3. Inside the loop:
a. Read keyboard input (for now, just from `stdin`).
b. Update the player's position and direction based on input (`W`, `A`, `S`, `D` for movement, `Q`, `E` for rotation). Implement basic collision detection against walls.
c. Call the `renderer.Render` function to draw the current view into the `Screen` buffer.
d. Convert the `Screen` buffer into a single string with all the necessary ANSI escape codes.
e. Print the string to the console. Start with `\x1b[H` to reset the cursor.
---
### **Phase 2: The SSH Server**
**Goal:** Wrap the single-player engine in an SSH server, allowing users to connect and play.
**Step 1: Server Setup**
* Modify `main.go` to be the server entry point.
* Use `github.com/gliderlabs/ssh` to create a new SSH server.
* Configure the server to listen on a port (e.g., `:2222`).
* Define a handler function that will be executed for each new SSH connection.
**Step 2: Session Handling**
* Inside the SSH handler:
1. Accept the connection. A `ssh.Session` object is provided.
2. Get the terminal dimensions using `pty.Getsize(session)`. This is critical for setting the render width and height.
3. Create a new `Player` instance for this session and place them at a default spawn point.
4. Launch a new **goroutine** that contains the **game loop** for this specific player. Pass the `ssh.Session` object to it so it knows where to send the rendered frames.
5. The main part of the handler will block, waiting for keyboard input from the `session`. Read the input and send it to the player's game loop goroutine via a channel.
6. When the session ends (player disconnects), make sure to clean up the goroutine and the player object.
**Step 3: State Management**
* The `Map` will be a shared resource for all players.
* Each player's `Player` object is their own unique state.
* For now, players will not see each other. They will exist in the same world but be invisible to one another.
---
### **Phase 3: Gameplay & Interactivity**
**Goal:** Add core FPS mechanics like shooting, enemies, and interactive environments.
**Step 1: Adding Sprites (Enemies/Objects)**
* Modify the renderer to also handle sprites. After rendering the walls (the Z-buffer), draw sprites.
* This involves:
1. Translating sprite coordinates relative to the player.
2. Sorting sprites from farthest to nearest.
3. Drawing them column by column, making sure not to draw behind walls that are closer.
* Enemies can be simple state machines: patrol, spot player, chase, attack.
**Step 2: Shooting Mechanic**
* When a player "shoots" (e.g., presses Space), perform another raycast from the player's position in their current direction.
* If this ray hits an enemy sprite before it hits a wall, register a hit.
**Step 3: Player State & HUD**
* Add health and ammo to the `Player` struct.
* Overlay a simple HUD on the bottom of the screen when rendering, displaying this information.
**Step 4: Multiplayer Synchronization**
* To make players visible to each other, they need to be rendered as sprites.
* The server will need a central list of all connected players.
* The renderer for each player will now iterate through all *other* players and render them as sprites.
* **Challenge:** This will require thread-safe access to the list of players (using mutexes) to prevent race conditions as players connect and disconnect.
This comprehensive plan should provide a clear roadmap for the AI to build this exciting project, starting from the core rendering logic and progressively adding complexity and features.

BIN
wolf3d.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB