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-10.0 { blocked = true break } } } if !blocked { return false } } return true }