Files
LilGuy/internal/raycast/raycast.go
2025-12-16 00:44:52 -07:00

354 lines
7.5 KiB
Go

package raycast
import (
"image/color"
"math"
"sort"
"github.com/atridad/LilGuy/internal/world"
"github.com/hajimehoshi/ebiten/v2"
)
// Line represents a line segment
type Line struct {
X1, Y1, X2, Y2 float64
}
// angle returns the angle of the line
func (l *Line) angle() float64 {
return math.Atan2(l.Y2-l.Y1, l.X2-l.X1)
}
// LightSource represents a light that casts rays
type LightSource struct {
X, Y float64
Radius float64
Color color.RGBA
Intensity float64
}
type System struct {
lights []*LightSource
shadowImage *ebiten.Image
triangleImage *ebiten.Image
screenWidth int
screenHeight int
enabled bool
shadowIntensity float64
}
func NewSystem(screenWidth, screenHeight int) *System {
shadowImage := ebiten.NewImage(screenWidth, screenHeight)
triangleImage := ebiten.NewImage(screenWidth, screenHeight)
triangleImage.Fill(color.White)
return &System{
lights: make([]*LightSource, 0),
shadowImage: shadowImage,
triangleImage: triangleImage,
screenWidth: screenWidth,
screenHeight: screenHeight,
enabled: true,
shadowIntensity: 0.7,
}
}
func (s *System) AddLight(light *LightSource) {
s.lights = append(s.lights, light)
}
func (s *System) ClearLights() {
s.lights = s.lights[:0]
}
func (s *System) SetEnabled(enabled bool) {
s.enabled = enabled
}
func (s *System) IsEnabled() bool {
return s.enabled
}
func (s *System) SetShadowIntensity(intensity float64) {
s.shadowIntensity = intensity
}
func worldToLines(w *world.World) []Line {
var lines []Line
for _, surface := range w.Surfaces {
if !surface.IsSolid() {
continue
}
x, y, width, height := surface.Bounds()
lines = append(lines,
Line{x, y, x + width, y},
Line{x + width, y, x + width, y + height},
Line{x + width, y + height, x, y + height},
Line{x, y + height, x, y},
)
}
return lines
}
// intersection calculates the intersection of two lines
func intersection(l1, l2 Line) (float64, float64, bool) {
denom := (l1.X1-l1.X2)*(l2.Y1-l2.Y2) - (l1.Y1-l1.Y2)*(l2.X1-l2.X2)
tNum := (l1.X1-l2.X1)*(l2.Y1-l2.Y2) - (l1.Y1-l2.Y1)*(l2.X1-l2.X2)
uNum := -((l1.X1-l1.X2)*(l1.Y1-l2.Y1) - (l1.Y1-l1.Y2)*(l1.X1-l2.X1))
if denom == 0 {
return 0, 0, false
}
t := tNum / denom
if t > 1 || t < 0 {
return 0, 0, false
}
u := uNum / denom
if u > 1 || u < 0 {
return 0, 0, false
}
x := l1.X1 + t*(l1.X2-l1.X1)
y := l1.Y1 + t*(l1.Y2-l1.Y1)
return x, y, true
}
// newRay creates a ray from a point at a given angle
func newRay(x, y, length, angle float64) Line {
return Line{
X1: x,
Y1: y,
X2: x + length*math.Cos(angle),
Y2: y + length*math.Sin(angle),
}
}
// getLinePoints extracts unique points from lines
func getLinePoints(lines []Line) [][2]float64 {
pointMap := make(map[[2]float64]bool)
for _, line := range lines {
pointMap[[2]float64{line.X1, line.Y1}] = true
pointMap[[2]float64{line.X2, line.Y2}] = true
}
points := make([][2]float64, 0, len(pointMap))
for p := range pointMap {
points = append(points, p)
}
return points
}
func castRays(cx, cy float64, lines []Line) []Line {
const rayLength = 2000.0
var rays []Line
points := getLinePoints(lines)
for _, p := range points {
l := Line{cx, cy, p[0], p[1]}
angle := l.angle()
for _, offset := range []float64{-0.001, 0.001} {
var intersections [][2]float64
ray := newRay(cx, cy, rayLength, angle+offset)
for _, wall := range lines {
if px, py, ok := intersection(ray, wall); ok {
intersections = append(intersections, [2]float64{px, py})
}
}
if len(intersections) > 0 {
minDist := math.Inf(1)
minIdx := -1
for i, point := range intersections {
dist := (cx-point[0])*(cx-point[0]) + (cy-point[1])*(cy-point[1])
if dist < minDist {
minDist = dist
minIdx = i
}
}
if minIdx >= 0 {
rays = append(rays, Line{cx, cy, intersections[minIdx][0], intersections[minIdx][1]})
}
}
}
}
sort.Slice(rays, func(i, j int) bool {
return rays[i].angle() < rays[j].angle()
})
return rays
}
// rayVertices creates vertices for a triangle between rays
func rayVertices(x1, y1, x2, y2, x3, y3 float64) []ebiten.Vertex {
return []ebiten.Vertex{
{DstX: float32(x1), DstY: float32(y1), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1},
{DstX: float32(x2), DstY: float32(y2), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1},
{DstX: float32(x3), DstY: float32(y3), SrcX: 0, SrcY: 0, ColorR: 1, ColorG: 1, ColorB: 1, ColorA: 1},
}
}
func (s *System) Draw(screen *ebiten.Image, w *world.World) {
if !s.enabled || len(s.lights) == 0 {
return
}
lines := worldToLines(w)
sw := float64(s.screenWidth)
sh := float64(s.screenHeight)
lines = append(lines,
Line{0, 0, sw, 0},
Line{sw, 0, sw, sh},
Line{sw, sh, 0, sh},
Line{0, sh, 0, 0},
)
s.shadowImage.Fill(color.RGBA{0, 0, 0, 180})
for _, light := range s.lights {
rays := castRays(light.X, light.Y, lines)
opt := &ebiten.DrawTrianglesOptions{}
opt.Blend = ebiten.BlendSourceOut
for i, ray := range rays {
nextRay := rays[(i+1)%len(rays)]
v := rayVertices(light.X, light.Y, nextRay.X2, nextRay.Y2, ray.X2, ray.Y2)
s.shadowImage.DrawTriangles(v, []uint16{0, 1, 2}, s.triangleImage, opt)
}
}
shadowOpt := &ebiten.DrawImageOptions{}
shadowOpt.ColorScale.ScaleAlpha(float32(s.shadowIntensity))
screen.DrawImage(s.shadowImage, shadowOpt)
}
func (s *System) DrawDebug(screen *ebiten.Image, w *world.World) {
if !s.enabled || len(s.lights) == 0 {
return
}
lines := worldToLines(w)
sw := float64(s.screenWidth)
sh := float64(s.screenHeight)
lines = append(lines,
Line{0, 0, sw, 0},
Line{sw, 0, sw, sh},
Line{sw, sh, 0, sh},
Line{0, sh, 0, 0},
)
for _, light := range s.lights {
rays := castRays(light.X, light.Y, lines)
for _, ray := range rays {
drawLine(screen, ray, color.RGBA{255, 255, 0, 150})
}
drawCircle(screen, light.X, light.Y, 5, color.RGBA{255, 200, 100, 255})
}
}
// Helper function to draw a line
func drawLine(screen *ebiten.Image, l Line, c color.RGBA) {
x1, y1 := int(l.X1), int(l.Y1)
x2, y2 := int(l.X2), int(l.Y2)
dx := abs(x2 - x1)
dy := abs(y2 - y1)
sx := -1
if x1 < x2 {
sx = 1
}
sy := -1
if y1 < y2 {
sy = 1
}
err := dx - dy
for {
if x1 >= 0 && x1 < screen.Bounds().Dx() && y1 >= 0 && y1 < screen.Bounds().Dy() {
screen.Set(x1, y1, c)
}
if x1 == x2 && y1 == y2 {
break
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x1 += sx
}
if e2 < dx {
err += dx
y1 += sy
}
}
}
// Helper function to draw a circle
func drawCircle(screen *ebiten.Image, cx, cy, radius float64, c color.RGBA) {
for angle := 0.0; angle < 2*math.Pi; angle += 0.1 {
x := int(cx + radius*math.Cos(angle))
y := int(cy + radius*math.Sin(angle))
if x >= 0 && x < screen.Bounds().Dx() && y >= 0 && y < screen.Bounds().Dy() {
screen.Set(x, y, c)
}
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func (s *System) IsPointInShadow(x, y float64, w *world.World) bool {
if !s.enabled || len(s.lights) == 0 {
return false
}
lines := worldToLines(w)
for _, light := range s.lights {
rayToPoint := Line{light.X, light.Y, x, y}
distanceToPoint := math.Sqrt((x-light.X)*(x-light.X) + (y-light.Y)*(y-light.Y))
blocked := false
for _, wall := range lines {
if ix, iy, ok := intersection(rayToPoint, wall); ok {
distanceToIntersection := math.Sqrt((ix-light.X)*(ix-light.X) + (iy-light.Y)*(iy-light.Y))
if distanceToIntersection < distanceToPoint-5.0 {
blocked = true
break
}
}
}
if !blocked {
return false
}
}
return true
}