From de5f47f47bd33434df196c6aef48f5d617fa6648 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Tue, 16 Dec 2025 00:44:52 -0700 Subject: [PATCH] Lighting experiments --- go.mod | 16 +- go.sum | 17 ++ internal/game/game.go | 20 +- internal/hero/hero.go | 9 + internal/maps/desert.go | 46 ++++- internal/maps/map.go | 11 +- internal/maps/plains.go | 1 + internal/raycast/raycast.go | 353 +++++++++++++++++++++++++++++++++++ internal/screens/gameplay.go | 147 ++++++++++++++- internal/screens/settings.go | 53 +++++- internal/screens/title.go | 4 + internal/ui/menu/menu.go | 4 + 12 files changed, 664 insertions(+), 17 deletions(-) create mode 100644 internal/raycast/raycast.go diff --git a/go.mod b/go.mod index a123fe6..fb3bd92 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,19 @@ module github.com/atridad/LilGuy go 1.25.4 require ( - github.com/hajimehoshi/ebiten/v2 v2.9.4 - golang.org/x/image v0.31.0 + github.com/hajimehoshi/ebiten/v2 v2.9.6 + golang.org/x/image v0.34.0 ) require ( github.com/BurntSushi/toml v1.5.0 github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect github.com/ebitengine/hideconsole v1.0.0 // indirect - github.com/ebitengine/purego v0.9.0 // indirect - github.com/go-text/typesetting v0.3.0 // indirect - github.com/jezek/xgb v1.1.1 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/go-text/typesetting v0.3.1 // indirect + github.com/jezek/xgb v1.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 9d1fa28..23b0636 100644 --- a/go.sum +++ b/go.sum @@ -6,25 +6,42 @@ github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/go-text/typesetting v0.3.1 h1:ESHfFntFnJOigjEeEiTc3OGXqggC1eSAAqHkG9ZB+yA= +github.com/go-text/typesetting v0.3.1/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs= github.com/hajimehoshi/bitmapfont/v4 v4.1.0 h1:eE3qa5Do4qhowZVIHjsrX5pYyyPN6sAFWMsO7QREm3U= github.com/hajimehoshi/bitmapfont/v4 v4.1.0/go.mod h1:/PD+aLjAJ0F2UoQx6hkOfXqWN7BkroDUMr5W+IT1dpE= github.com/hajimehoshi/ebiten/v2 v2.9.4 h1:IlPJpwtksylmmvNhQjv4W2bmCFWXtjY7Z10Esise1bk= github.com/hajimehoshi/ebiten/v2 v2.9.4/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/hajimehoshi/ebiten/v2 v2.9.6 h1:uP41hMkfcbfEfgiTlpzhgnTHGAAfbM/v/pNOZkelI78= +github.com/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jezek/xgb v1.2.0 h1:LzgkD11wOrPnxXEqo588cnjUt4NwMHrFh/tgajo50Q0= +github.com/jezek/xgb v1.2.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/internal/game/game.go b/internal/game/game.go index 6519c82..7bed1f4 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -6,10 +6,10 @@ import ( "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/atridad/LilGuy/internal/maps" "github.com/atridad/LilGuy/internal/save" "github.com/atridad/LilGuy/internal/screens" "github.com/atridad/LilGuy/internal/ui/menu" - "github.com/atridad/LilGuy/internal/maps" ) const ( @@ -105,6 +105,8 @@ type state struct { fpsEnabled bool fpsCap FPSCap portalVisibility bool + raycastEnabled bool + raycastDebugMode bool saveManager *save.Manager lastAutoSave time.Time @@ -123,6 +125,8 @@ func newState() *state { fpsEnabled: false, fpsCap: FPSCap60, portalVisibility: false, + raycastEnabled: true, + raycastDebugMode: false, lastAutoSave: now, autoSaveInterval: 30 * time.Second, } @@ -165,16 +169,18 @@ func newState() *state { // Initialize screens s.splashScreen = screens.NewSplashScreen() s.titleScreen = screens.NewTitleScreen() - s.gameplayScreen = screens.NewGameplayScreen(ScreenWidth, ScreenHeight, mapManager, &s.fpsEnabled, &s.portalVisibility) + s.gameplayScreen = screens.NewGameplayScreen(ScreenWidth, ScreenHeight, mapManager, &s.fpsEnabled, &s.portalVisibility, &s.raycastEnabled, &s.raycastDebugMode) s.pauseMenu = menu.NewPauseMenu() // Wire up settings references s.titleScreen.SetFPSMonitor(&s.fpsEnabled) s.titleScreen.SetFPSCap(&s.fpsCap) s.titleScreen.SetPortalVisibility(&s.portalVisibility) + s.titleScreen.SetRaycastSettings(&s.raycastEnabled, &s.raycastDebugMode) s.pauseMenu.SetFPSMonitor(&s.fpsEnabled) s.pauseMenu.SetFPSCap(&s.fpsCap) s.pauseMenu.SetPortalVisibility(&s.portalVisibility) + s.pauseMenu.SetRaycastSettings(&s.raycastEnabled, &s.raycastDebugMode) if saveManager != nil { s.titleScreen.SetHasSaveGame(saveManager.HasSavedGame()) @@ -282,6 +288,16 @@ func (g *Game) updatePlaying() error { return nil } + // Toggle raycasting with L key + if inpututil.IsKeyJustPressed(ebiten.KeyL) { + g.state.gameplayScreen.ToggleRaycast() + } + + // Toggle raycasting debug mode with B key + if inpututil.IsKeyJustPressed(ebiten.KeyB) { + g.state.gameplayScreen.ToggleRaycastDebug() + } + now := time.Now() delta := now.Sub(g.state.lastTick) g.state.lastTick = now diff --git a/internal/hero/hero.go b/internal/hero/hero.go index c85b713..7bb15ec 100644 --- a/internal/hero/hero.go +++ b/internal/hero/hero.go @@ -90,6 +90,10 @@ type Hero struct { lastAnimKey animationKey ProjectileConfig projectile.ProjectileConfig + + // Lighting state + InShadow bool + ShadowIntensity float64 } type Config struct { @@ -358,6 +362,11 @@ func (h *Hero) Draw(screen *ebiten.Image) { // no color modification for idle } + if h.InShadow { + darkness := 1.0 - h.ShadowIntensity + op.ColorScale.Scale(float32(darkness), float32(darkness), float32(darkness), 1.0) + } + op.Filter = ebiten.FilterNearest screen.DrawImage(sprite, op) } diff --git a/internal/maps/desert.go b/internal/maps/desert.go index 1d7f575..df32bd9 100644 --- a/internal/maps/desert.go +++ b/internal/maps/desert.go @@ -10,7 +10,8 @@ import ( func CreateDesert(screenWidth, screenHeight float64) *Map { m := NewMap("desert", 2, "Desert", screenWidth, screenHeight) - m.BackgroundColor = color.NRGBA{R: 155, G: 196, B: 215, A: 255} + m.BackgroundColor = color.NRGBA{R: 15, G: 20, B: 40, A: 255} // Dark blue night sky + m.TimeOfDay = Nighttime // ground surface m.World.AddSurface(&world.Surface{ @@ -22,6 +23,49 @@ func CreateDesert(screenWidth, screenHeight float64) *Map { Color: color.NRGBA{R: 139, G: 69, B: 19, A: 255}, }) + // Platforms - sandy brown color + platformColor := color.NRGBA{R: 194, G: 134, B: 64, A: 255} + + // Platform 1: Low left + m.World.AddSurface(&world.Surface{ + X: 120, + Y: screenHeight - 110, + Width: 140, + Height: 20, + Tag: world.TagPlatform, + Color: platformColor, + }) + + // Platform 2: Mid center-left + m.World.AddSurface(&world.Surface{ + X: 320, + Y: screenHeight - 180, + Width: 180, + Height: 20, + Tag: world.TagPlatform, + Color: platformColor, + }) + + // Platform 3: High center-right + m.World.AddSurface(&world.Surface{ + X: 580, + Y: screenHeight - 230, + Width: 160, + Height: 20, + Tag: world.TagPlatform, + Color: platformColor, + }) + + // Platform 4: Mid right + m.World.AddSurface(&world.Surface{ + X: 720, + Y: screenHeight - 140, + Width: 140, + Height: 20, + Tag: world.TagPlatform, + Color: platformColor, + }) + // left portal to plains leftPortal := portal.CreateSidePortal("desert_left", portal.SideLeft, screenWidth, screenHeight) leftPortal.DestinationMap = "plains" diff --git a/internal/maps/map.go b/internal/maps/map.go index 3e14abe..becdd12 100644 --- a/internal/maps/map.go +++ b/internal/maps/map.go @@ -3,9 +3,16 @@ package maps import ( "image/color" - "github.com/hajimehoshi/ebiten/v2" "github.com/atridad/LilGuy/internal/portal" "github.com/atridad/LilGuy/internal/world" + "github.com/hajimehoshi/ebiten/v2" +) + +type TimeOfDay string + +const ( + Daytime TimeOfDay = "day" + Nighttime TimeOfDay = "night" ) type Map struct { @@ -17,6 +24,7 @@ type Map struct { World *world.World Portals []*portal.Portal BackgroundColor color.NRGBA + TimeOfDay TimeOfDay // Day or Night tag bakedImage *ebiten.Image } @@ -31,6 +39,7 @@ func NewMap(id string, number int, displayName string, width, height float64) *M World: world.NewWorld(), Portals: make([]*portal.Portal, 0), BackgroundColor: color.NRGBA{R: 135, G: 206, B: 235, A: 255}, + TimeOfDay: Daytime, // Default to daytime } } diff --git a/internal/maps/plains.go b/internal/maps/plains.go index 904d390..256e428 100644 --- a/internal/maps/plains.go +++ b/internal/maps/plains.go @@ -11,6 +11,7 @@ import ( func CreatePlains(screenWidth, screenHeight float64) *Map { m := NewMap("plains", 1, "Plains", screenWidth, screenHeight) m.BackgroundColor = color.NRGBA{R: 135, G: 206, B: 235, A: 255} + m.TimeOfDay = Daytime // ground surface groundColor := color.NRGBA{R: 34, G: 139, B: 34, A: 255} diff --git a/internal/raycast/raycast.go b/internal/raycast/raycast.go new file mode 100644 index 0000000..cf02a6b --- /dev/null +++ b/internal/raycast/raycast.go @@ -0,0 +1,353 @@ +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 +} diff --git a/internal/screens/gameplay.go b/internal/screens/gameplay.go index f4a8426..658cd8d 100644 --- a/internal/screens/gameplay.go +++ b/internal/screens/gameplay.go @@ -14,6 +14,7 @@ import ( "github.com/atridad/LilGuy/internal/maps" "github.com/atridad/LilGuy/internal/portal" "github.com/atridad/LilGuy/internal/projectile" + "github.com/atridad/LilGuy/internal/raycast" "github.com/atridad/LilGuy/internal/save" "github.com/atridad/LilGuy/internal/status" "github.com/atridad/LilGuy/internal/ui/hud" @@ -48,9 +49,14 @@ type GameplayScreen struct { showSaveNotification bool portalVisibility *bool + + // Raycasting system + raycastSystem *raycast.System + raycastEnabled *bool + raycastDebugMode *bool } -func NewGameplayScreen(screenWidth, screenHeight int, mapManager *maps.Manager, fpsEnabled *bool, portalVisibility *bool) *GameplayScreen { +func NewGameplayScreen(screenWidth, screenHeight int, mapManager *maps.Manager, fpsEnabled *bool, portalVisibility *bool, raycastEnabled *bool, raycastDebugMode *bool) *GameplayScreen { cfg := config.Default() // Ensure we have a current map @@ -100,6 +106,9 @@ func NewGameplayScreen(screenWidth, screenHeight int, mapManager *maps.Manager, gameStartTime: time.Now(), fpsEnabled: fpsEnabled, portalVisibility: portalVisibility, + raycastSystem: raycast.NewSystem(screenWidth, screenHeight), + raycastEnabled: raycastEnabled, + raycastDebugMode: raycastDebugMode, } gs.portals.OnTransition = gs.handlePortalTransition @@ -132,6 +141,9 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) { g.projectiles.Update(dt, g.bounds.Width, g.bounds.Height) g.portals.Update(dt) + // Update raycasting lights based on current map + g.updateRaycastLights() + // check for portal collisions g.checkPortalCollision() @@ -148,6 +160,40 @@ func (g *GameplayScreen) Update(input GameplayInput, delta time.Duration) { } } +func (g *GameplayScreen) updateRaycastLights() { + g.raycastSystem.ClearLights() + + currentMap := g.mapManager.CurrentMap() + if currentMap == nil { + return + } + + screenWidth := g.bounds.Width + + lightX := float64(screenWidth - 80) + lightY := 80.0 + + if currentMap.TimeOfDay == maps.Daytime { + g.raycastSystem.SetShadowIntensity(0.25) + g.raycastSystem.AddLight(&raycast.LightSource{ + X: lightX, + Y: lightY, + Radius: 800.0, + Color: color.RGBA{R: 255, G: 250, B: 220, A: 255}, + Intensity: 1.0, + }) + } else { + g.raycastSystem.SetShadowIntensity(0.7) + g.raycastSystem.AddLight(&raycast.LightSource{ + X: lightX, + Y: lightY, + Radius: 500.0, + Color: color.RGBA{R: 200, G: 220, B: 255, A: 255}, + Intensity: 0.7, + }) + } +} + // portal collision detection func (g *GameplayScreen) checkPortalCollision() { heroRadius := g.hero.Radius @@ -229,6 +275,26 @@ func (g *GameplayScreen) Draw(screen *ebiten.Image) { if currentMap != nil { currentMap.Draw(screen) g.hud.ScreenName = currentMap.DisplayName + + // Apply nighttime darkening overlay BEFORE raycasting + if currentMap.TimeOfDay == maps.Nighttime { + g.drawNightOverlay(screen) + } + + // Draw raycasting shadows/lighting + if g.raycastEnabled != nil && *g.raycastEnabled { + g.raycastSystem.Draw(screen, g.world) + + if g.raycastDebugMode != nil && *g.raycastDebugMode { + g.raycastSystem.DrawDebug(screen, g.world) + } + } + + // Draw sun or moon circle in top-right + g.drawCelestialBody(screen, currentMap) + + // Update hero lighting state + g.updateHeroLighting(currentMap) } else { screen.Fill(cfg.Visual.BackgroundColor) } @@ -307,6 +373,55 @@ func (g *GameplayScreen) drawSaveNotification(screen *ebiten.Image) { ebitenutil.DebugPrintAt(screen, msg, int(textX), int(textY)) } +func (g *GameplayScreen) updateHeroLighting(currentMap *maps.Map) { + if g.raycastEnabled == nil || !*g.raycastEnabled { + g.hero.InShadow = false + g.hero.ShadowIntensity = 0 + return + } + + // Check if hero is in shadow + inShadow := g.raycastSystem.IsPointInShadow(g.hero.X, g.hero.Y, g.world) + g.hero.InShadow = inShadow + + if inShadow { + if currentMap.TimeOfDay == maps.Daytime { + g.hero.ShadowIntensity = 0.3 + } else { + g.hero.ShadowIntensity = 0.6 + } + } else { + g.hero.ShadowIntensity = 0 + } +} + +func (g *GameplayScreen) drawNightOverlay(screen *ebiten.Image) { + opts := &ebiten.DrawImageOptions{} + opts.ColorScale.Scale(0.4, 0.4, 0.6, 1.0) + + overlay := ebiten.NewImage(int(g.bounds.Width), int(g.bounds.Height)) + overlay.Fill(color.RGBA{R: 0, G: 0, B: 30, A: 120}) + screen.DrawImage(overlay, &ebiten.DrawImageOptions{}) +} + +func (g *GameplayScreen) drawCelestialBody(screen *ebiten.Image, currentMap *maps.Map) { + if currentMap == nil { + return + } + + cx := float32(g.bounds.Width - 80) + cy := float32(80) + radius := float32(25) + + if currentMap.TimeOfDay == maps.Daytime { + vector.DrawFilledCircle(screen, cx, cy, radius, color.RGBA{R: 255, G: 230, B: 100, A: 255}, true) + vector.DrawFilledCircle(screen, cx, cy, radius+3, color.RGBA{R: 255, G: 240, B: 150, A: 100}, true) + } else { + vector.DrawFilledCircle(screen, cx, cy, radius, color.RGBA{R: 240, G: 240, B: 255, A: 255}, true) + vector.DrawFilledCircle(screen, cx, cy, radius+3, color.RGBA{R: 200, G: 200, B: 255, A: 80}, true) + } +} + func (g *GameplayScreen) ShowSaveNotification() { g.showSaveNotification = true g.saveNotificationTimer = config.SaveNotificationDuration @@ -431,3 +546,33 @@ func (g *GameplayScreen) LoadState(state *save.GameState) { g.fpsAccumulator = 0 g.fpsValue = 0 } + +// ToggleRaycast toggles the raycasting lighting system on/off +func (g *GameplayScreen) ToggleRaycast() { + if g.raycastEnabled != nil { + *g.raycastEnabled = !*g.raycastEnabled + } +} + +// ToggleRaycastDebug toggles the raycasting debug visualization +func (g *GameplayScreen) ToggleRaycastDebug() { + if g.raycastDebugMode != nil { + *g.raycastDebugMode = !*g.raycastDebugMode + } +} + +// IsRaycastEnabled returns whether raycasting is enabled +func (g *GameplayScreen) IsRaycastEnabled() bool { + if g.raycastEnabled == nil { + return false + } + return *g.raycastEnabled +} + +// IsRaycastDebugMode returns whether raycasting debug mode is enabled +func (g *GameplayScreen) IsRaycastDebugMode() bool { + if g.raycastDebugMode == nil { + return false + } + return *g.raycastDebugMode +} diff --git a/internal/screens/settings.go b/internal/screens/settings.go index 0f07fcf..b81b08d 100644 --- a/internal/screens/settings.go +++ b/internal/screens/settings.go @@ -27,6 +27,8 @@ type SettingsScreen struct { fpsMonitorValue *bool fpsCapValue FPSCapSetting portalVisibilityValue *bool + raycastEnabledValue *bool + raycastDebugValue *bool } func NewSettingsScreen() *SettingsScreen { @@ -48,6 +50,11 @@ func (s *SettingsScreen) SetPortalVisibility(enabled *bool) { s.portalVisibilityValue = enabled } +func (s *SettingsScreen) SetRaycastSettings(enabled *bool, debugMode *bool) { + s.raycastEnabledValue = enabled + s.raycastDebugValue = debugMode +} + func (s *SettingsScreen) Update() bool { if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { if s.currentScreen == settingsDebugOptions { @@ -92,7 +99,7 @@ func (s *SettingsScreen) updateMain() bool { } func (s *SettingsScreen) updateDebugOptions() bool { - debugOptionsCount := 3 + debugOptionsCount := 5 // FPS Monitor, Portal Visibility, Raycast Enabled, Raycast Debug, Back if inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) { s.selectedIndex-- if s.selectedIndex < 0 { @@ -111,7 +118,11 @@ func (s *SettingsScreen) updateDebugOptions() bool { *s.fpsMonitorValue = !*s.fpsMonitorValue } else if s.selectedIndex == 1 && s.portalVisibilityValue != nil { *s.portalVisibilityValue = !*s.portalVisibilityValue - } else if s.selectedIndex == 2 { + } else if s.selectedIndex == 2 && s.raycastEnabledValue != nil { + *s.raycastEnabledValue = !*s.raycastEnabledValue + } else if s.selectedIndex == 3 && s.raycastDebugValue != nil { + *s.raycastDebugValue = !*s.raycastDebugValue + } else if s.selectedIndex == 4 { s.currentScreen = settingsMain s.selectedIndex = 0 } @@ -211,10 +222,44 @@ func (s *SettingsScreen) drawDebugOptions(screen *ebiten.Image, screenWidth, scr s.drawText(screen, portalVisText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, portalY) } + // Raycast enabled toggle + raycastText := "Lighting: " + if s.raycastEnabledValue != nil && *s.raycastEnabledValue { + raycastText += "ON" + } else { + raycastText += "OFF" + } + + raycastY := startY + 80 + if s.selectedIndex == 2 { + indicatorX := leftMargin - 20 + s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, raycastY) + s.drawText(screen, raycastText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, raycastY) + } else { + s.drawText(screen, raycastText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, raycastY) + } + + // Raycast debug toggle + raycastDebugText := "Debug Rays: " + if s.raycastDebugValue != nil && *s.raycastDebugValue { + raycastDebugText += "ON" + } else { + raycastDebugText += "OFF" + } + + raycastDebugY := startY + 120 + if s.selectedIndex == 3 { + indicatorX := leftMargin - 20 + s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, raycastDebugY) + s.drawText(screen, raycastDebugText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, raycastDebugY) + } else { + s.drawText(screen, raycastDebugText, color.RGBA{R: 180, G: 180, B: 200, A: 255}, leftMargin, raycastDebugY) + } + // back option backText := "< Back" - backY := startY + 80 - if s.selectedIndex == 2 { + backY := startY + 160 + if s.selectedIndex == 4 { indicatorX := leftMargin - 20 s.drawText(screen, ">", color.RGBA{R: 255, G: 200, B: 0, A: 255}, indicatorX, backY) s.drawText(screen, backText, color.RGBA{R: 255, G: 255, B: 100, A: 255}, leftMargin, backY) diff --git a/internal/screens/title.go b/internal/screens/title.go index e5331be..da16155 100644 --- a/internal/screens/title.go +++ b/internal/screens/title.go @@ -55,6 +55,10 @@ func (t *TitleScreen) SetPortalVisibility(enabled *bool) { t.settingsScreen.SetPortalVisibility(enabled) } +func (t *TitleScreen) SetRaycastSettings(enabled *bool, debugMode *bool) { + t.settingsScreen.SetRaycastSettings(enabled, debugMode) +} + func (t *TitleScreen) SetHasSaveGame(hasSave bool) { t.hasSaveGame = hasSave if !hasSave && t.selectedIndex == 0 { diff --git a/internal/ui/menu/menu.go b/internal/ui/menu/menu.go index 2d9ace5..90dfbec 100644 --- a/internal/ui/menu/menu.go +++ b/internal/ui/menu/menu.go @@ -88,6 +88,10 @@ func (m *PauseMenu) SetPortalVisibility(enabled *bool) { m.settingsScreen.SetPortalVisibility(enabled) } +func (m *PauseMenu) SetRaycastSettings(enabled *bool, debugMode *bool) { + m.settingsScreen.SetRaycastSettings(enabled, debugMode) +} + // Update logic func (m *PauseMenu) Update() *MenuOption {