diff --git a/animation.go b/animation.go new file mode 100644 index 0000000..50ba6c9 --- /dev/null +++ b/animation.go @@ -0,0 +1,117 @@ +package tins2021 + +import ( + "math/rand" + "time" + + "opslag.de/schobers/geom" + "opslag.de/schobers/zntg/ui" +) + +type AnimatedTexture struct { + Texture ui.Texture + Frames []geom.RectangleF32 +} + +func NewAnimatedTexture(texture ui.Texture, n int) AnimatedTexture { + frames := make([]geom.RectangleF32, 0, n) + height := float32(texture.Height()) + width := float32(texture.Width()) + for i := 0; i < n; i++ { + left := width * float32(i) / float32(n) + right := width * float32(i+1) / float32(n) + frames = append(frames, geom.RectF32(left, 0, right, height)) + } + return AnimatedTexture{Texture: texture, Frames: frames} +} + +func NewSquareAnimatedTexture(texture ui.Texture) AnimatedTexture { + var frames []geom.RectangleF32 + height := float32(texture.Height()) + width := float32(texture.Width()) + for left := float32(0); left < width; left += height { + frames = append(frames, geom.RectF32(left, 0, left+height, height)) + } + return AnimatedTexture{Texture: texture, Frames: frames} +} + +func (t AnimatedTexture) Scale(scale float32) AnimatedTexture { + frames := make([]geom.RectangleF32, 0, len(t.Frames)) + for _, frame := range t.Frames { + frames = append(frames, geom.RectangleF32{Min: frame.Min.Mul(scale), Max: frame.Max.Mul(scale)}) + } + return AnimatedTexture{ + Texture: t.Texture, + Frames: frames, + } +} + +func (t AnimatedTexture) Draw(renderer ui.Renderer, pos geom.PointF32, frame int) { + renderer.DrawTexturePointOptions(t.Texture, pos, ui.DrawOptions{Source: &t.Frames[frame]}) +} + +type Animation struct { + LastUpdate time.Time + Frame int +} + +type Animations struct { + Values map[geom.Point]*Animation + Interval time.Duration + Frames int + AutoReset bool + RandomInit bool +} + +func NewAnimations(interval time.Duration, frames int, autoReset, randomInit bool) *Animations { + return &Animations{ + Values: map[geom.Point]*Animation{}, + Interval: interval, + Frames: frames, + AutoReset: autoReset, + RandomInit: randomInit, + } +} + +func (a *Animations) Update() { + now := time.Now() + update := now.Add(-a.Interval) + for _, value := range a.Values { + if value.Frame == a.Frames { + break + } + for value.LastUpdate.Before(update) { + value.LastUpdate = value.LastUpdate.Add(a.Interval) + value.Frame = value.Frame + 1 + if value.Frame == a.Frames { + if a.AutoReset { + value.Frame = 0 + } else { + break + } + } + } + } +} + +func (a *Animations) newAnimation() *Animation { + if a.RandomInit { + return &Animation{ + LastUpdate: time.Now().Add(time.Duration(-rand.Int63n(int64(a.Interval)))), + Frame: rand.Intn(a.Frames), + } + } + return &Animation{ + LastUpdate: time.Now(), + Frame: 0, + } +} + +func (a *Animations) Frame(pos geom.Point) int { + value, ok := a.Values[pos] + if !ok { + value = a.newAnimation() + a.Values[pos] = value + } + return value.Frame +} diff --git a/bitmapfont.go b/bitmapfont.go new file mode 100644 index 0000000..4c20f77 --- /dev/null +++ b/bitmapfont.go @@ -0,0 +1,106 @@ +package tins2021 + +import ( + "image/color" + + "opslag.de/schobers/geom" + "opslag.de/schobers/zntg/ui" +) + +type BitmapFont struct { + texture ui.Texture + height float32 + runes map[rune]*geom.RectangleF32 +} + +var AllCharacters = []rune(`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ,.?!;:+-=_()[]{}"'@#$%^&*<>\/`) +var AlphaCharacters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") +var LowerCaseAlphaCharacters = []rune("abcdefghijklmnopqrstuvwxyz") +var NumericCharacters = []rune("0123456789") +var SpecialCharacters = []rune(` ,.?!;:+-=_()[]{}"'@#$%^&*<>\/`) +var UpperCaseAlphaCharacters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func NewBitmapFont(renderer ui.Renderer, font ui.Font, set ...rune) (*BitmapFont, error) { + texture, err := renderer.TextTexture(font, color.White, string(set)) + if err != nil { + return nil, err + } + runes := map[rune]*geom.RectangleF32{} + height := float32(texture.Height()) + var left float32 + for _, r := range set { + width := font.WidthOf(string([]rune{r})) + right := left + width + rect := geom.RectF32(left, 0, right, height) + runes[r] = &rect + left = right + } + return &BitmapFont{texture, font.Height(), runes}, nil +} + +func (f *BitmapFont) Destroy() error { return f.texture.Destroy() } +func (f *BitmapFont) Height() float32 { return f.height } + +func (f *BitmapFont) Measure(t string) geom.RectangleF32 { + if len(t) == 0 { + return geom.RectF32(0, 0, 0, 0) + } + var minY, maxY float32 + var width float32 + var first int + for i, r := range t { + rect := f.runes[r] + if rect == nil { + continue + } + width = rect.Dx() + minY, maxY = rect.Min.Y, rect.Max.Y + first = i + break + } + for _, r := range t[first+1:] { + rect := f.runes[r] + if rect == nil { + continue + } + width += rect.Dx() + if minY > rect.Min.Y { + minY = rect.Min.Y + } + if maxY < rect.Max.Y { + maxY = rect.Max.Y + } + } + return geom.RectF32(0, minY, width, maxY) +} + +func (f *BitmapFont) Text(renderer ui.Renderer, pos geom.PointF32, color color.Color, text string) { + f.text(renderer, pos.X, pos.Y, color, text) +} + +func (f *BitmapFont) TextAlign(renderer ui.Renderer, pos geom.PointF32, color color.Color, text string, align ui.HorizontalAlignment) { + left := pos.X + width := f.WidthOf(text) + switch align { + case ui.AlignCenter: + left -= .5 * width + case ui.AlignRight: + left -= width + } + f.text(renderer, left, pos.Y, color, text) +} + +func (f *BitmapFont) text(renderer ui.Renderer, left, top float32, color color.Color, text string) { + for _, r := range text { + src := f.runes[r] + if src == nil { + continue + } + renderer.DrawTexturePointOptions(f.texture, geom.PtF32(left, top), ui.DrawOptions{Tint: color, Source: src}) + left += src.Dx() + } +} + +func (f *BitmapFont) WidthOf(t string) float32 { + return f.Measure(t).Dx() +} diff --git a/cmd/tins2021/app.go b/cmd/tins2021/app.go index 9454381..7dd7ccd 100644 --- a/cmd/tins2021/app.go +++ b/cmd/tins2021/app.go @@ -41,6 +41,7 @@ func (a *app) Init(ctx ui.Context) error { if err := a.loadFonts(ctx, fontDescriptor{"debug", "fonts/FiraMono-Regular.ttf", 12}, fontDescriptor{"default", "fonts/escheresk.ttf", 32}, + fontDescriptor{"small", "fonts/escheresk.ttf", 16}, fontDescriptor{"title", "fonts/escher.ttf", 80}, ); err != nil { return err @@ -68,10 +69,10 @@ func (a *app) Handle(ctx ui.Context, e ui.Event) bool { case *ui.DisplayMoveEvent: location := e.Bounds.Min.ToInt() a.settings.Window.Location = &location - case *ui.DisplayResizeEvent: - a.Arrange(ctx, e.Bounds, geom.ZeroPtF32, nil) - size := e.Bounds.Size().ToInt() - a.settings.Window.Size = &size + // case *ui.DisplayResizeEvent: + // a.Arrange(ctx, e.Bounds, geom.ZeroPtF32, nil) + // size := e.Bounds.Size().ToInt() + // a.settings.Window.Size = &size case *ui.KeyDownEvent: switch e.Key { case ui.KeyEscape: @@ -124,7 +125,8 @@ func (s Sprite) Destroy() { s.Texture.Destroy() } func newIntroView(ctx ui.Context) *introView { view := &introView{} - level := tins2021.NewRandomLevel() + level := tins2021.NewLevel() + level.Randomize(100, 10) view.Children = []ui.Control{ label("QBITTER", "title"), diff --git a/cmd/tins2021/levelcontrol.go b/cmd/tins2021/levelcontrol.go deleted file mode 100644 index 32e3a9b..0000000 --- a/cmd/tins2021/levelcontrol.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "opslag.de/schobers/geom" - "opslag.de/schobers/tins2021" - "opslag.de/schobers/zntg" - "opslag.de/schobers/zntg/ui" -) - -type levelControl struct { - ui.ControlBase - - Scale float32 - Level *tins2021.Level -} - -func newLevelControl(ctx ui.Context, level *tins2021.Level) *levelControl { - renderer := &levelControl{Level: level, Scale: .3} - ctx.Textures().CreateTextureGo("cube1", tins2021.GenerateCube(tins2021.Orange), true) - ctx.Textures().CreateTextureGo("cube1_inversed", tins2021.GenerateHole(tins2021.Orange), true) - return renderer -} - -func IsModifierPressed(modifiers ui.KeyModifier, pressed ui.KeyModifier) bool { - return modifiers&pressed == pressed -} - -func (r levelControl) Handle(ctx ui.Context, e ui.Event) bool { - switch e := e.(type) { - case *ui.KeyDownEvent: - switch e.Key { - case ui.KeyW: - r.Level.MovePlayer(tins2021.DirectionUpLeft) - case ui.KeyD: - r.Level.MovePlayer(tins2021.DirectionUpRight) - case ui.KeyS: - r.Level.MovePlayer(tins2021.DirectionDownRight) - case ui.KeyA: - r.Level.MovePlayer(tins2021.DirectionDownLeft) - } - } - return false -} - -func (r levelControl) Render(ctx ui.Context) { - const twelfth = (1. / 6) * geom.Pi - renderer := ctx.Renderer() - - size := geom.Floor32(256. * r.Scale) - scale := size / 256 - - centerTopSquare := geom.PtF32(.5, .5*geom.Sin32(twelfth)) - delta := geom.PtF32(geom.Cos32(twelfth), .5+centerTopSquare.Y).Mul(size) - centerTopSquare = centerTopSquare.Mul(size) - - delta.X = geom.Round32(delta.X) - delta.Y = geom.Round32(delta.Y) - toScreen := func(p geom.Point) geom.PointF32 { - if p.Y%2 == 0 { - return p.ToF32().Mul2D(delta.XY()).Add2D(.5*delta.X, 0) - } - return p.ToF32().Mul2D(delta.XY()) - } - cube := ctx.Textures().ScaledByName("cube1", scale) - inversed := ctx.Textures().ScaledByName("cube1_inversed", scale) - player := ctx.Textures().ScaledByName("dwarf", scale) - - for pos, tile := range r.Level.Tiles { - if tile.Inversed { - renderer.DrawTexturePoint(inversed, toScreen(pos)) - } else { - renderer.DrawTexturePoint(cube, toScreen(pos)) - } - } - playerPosition := toScreen(r.Level.Player).Sub(geom.Pt(player.Width()/2, player.Height()).ToF32()) - if r.Level.Tiles[r.Level.Player].Inversed { - centerBottomSquare := geom.PtF32(centerTopSquare.X, size-centerTopSquare.Y) - renderer.DrawTexturePointOptions(player, playerPosition.Add(centerBottomSquare), ui.DrawOptions{ - Tint: zntg.MustHexColor(tins2021.Blue), - }) - } else { - renderer.DrawTexturePointOptions(player, playerPosition.Add(centerTopSquare), ui.DrawOptions{ - Tint: zntg.MustHexColor(tins2021.Lighten(tins2021.Blue, 0.1)), - }) - } -} diff --git a/cmd/tins2021/levelcontroller.go b/cmd/tins2021/levelcontroller.go new file mode 100644 index 0000000..d935932 --- /dev/null +++ b/cmd/tins2021/levelcontroller.go @@ -0,0 +1,226 @@ +package main + +import ( + "image/color" + "math/rand" + "strconv" + "time" + + "opslag.de/schobers/geom" + "opslag.de/schobers/tins2021" + "opslag.de/schobers/zntg/ui" +) + +type levelController struct { + ui.ControlBase + + Scale float32 + Level *tins2021.Level + + Cubes map[string]cubeTexture + Animations map[string]*tins2021.Animations + + MonsterTextureNames map[tins2021.MonsterType]string + IdleMonsters *tins2021.Animations + MovingMonsters *tins2021.Animations + + SmallFont *tins2021.BitmapFont +} + +func newLevelControl(ctx ui.Context, level *tins2021.Level) *levelController { + control := &levelController{Level: level, Scale: .6} + textures := ctx.Textures() + control.Cubes = map[string]cubeTexture{ + "regular": newCubeTexture(textures, tins2021.Orange), + "blocked": newCubeTexture(textures, tins2021.Purple), + "colored": newCubeTexture(textures, tins2021.Blue), + } + newAnimatedTexture(textures, "star", tins2021.CreateStar(5), tins2021.Yellow, tins2021.NewRotateAnimation(defaultAnimationFrames)) + newAnimatedTexture(textures, "heart", tins2021.CreateHeart(), tins2021.Red, tins2021.NewRotateAnimation(defaultAnimationFrames)) + + control.MonsterTextureNames = map[tins2021.MonsterType]string{ + tins2021.MonsterTypeStraight: "straight-walking-monster", + tins2021.MonsterTypeRandom: "random-walking-monster", + tins2021.MonsterTypeChaser: "chasing-monster", + } + newAnimatedTexture(textures, control.MonsterTextureNames[tins2021.MonsterTypeStraight], tins2021.CreateHexagon(), tins2021.Green, tins2021.NewWobbleAnimation(defaultAnimationFrames, 30)) + newAnimatedTexture(textures, control.MonsterTextureNames[tins2021.MonsterTypeRandom], tins2021.CreateHexagon(), tins2021.Blue, tins2021.NewWobbleAnimation(defaultAnimationFrames, 30)) + newAnimatedTexture(textures, control.MonsterTextureNames[tins2021.MonsterTypeChaser], tins2021.CreateHexagon(), tins2021.Purple, tins2021.NewWobbleAnimation(defaultAnimationFrames, 30)) + + small, err := tins2021.NewBitmapFont(ctx.Renderer(), ctx.Fonts().Font("small"), tins2021.AllCharacters...) + if err != nil { + panic(err) + } + control.SmallFont = small + control.Animations = map[string]*tins2021.Animations{ + "star": tins2021.NewAnimations(50*time.Millisecond, defaultAnimationFrames, true, true), + "heart": tins2021.NewAnimations(80*time.Millisecond, defaultAnimationFrames, true, true), + } + control.IdleMonsters = tins2021.NewAnimations(500*time.Millisecond, 100, false, false) + control.MovingMonsters = tins2021.NewAnimations(50*time.Millisecond, 20, false, false) + for monster := range level.Monsters { + control.IdleMonsters.Frame(monster) + } + for _, monster := range control.MonsterTextureNames { + control.Animations[monster] = tins2021.NewAnimations(80*time.Millisecond, defaultAnimationFrames, true, true) + } + + return control +} + +func IsModifierPressed(modifiers ui.KeyModifier, pressed ui.KeyModifier) bool { + return modifiers&pressed == pressed +} + +func (r levelController) Handle(ctx ui.Context, e ui.Event) bool { + switch e := e.(type) { + case *ui.KeyDownEvent: + switch e.Key { + case ui.KeyW: + r.Level.MovePlayer(tins2021.DirectionUpLeft) + case ui.KeyD: + r.Level.MovePlayer(tins2021.DirectionUpRight) + case ui.KeyS: + r.Level.MovePlayer(tins2021.DirectionDownRight) + case ui.KeyA: + r.Level.MovePlayer(tins2021.DirectionDownLeft) + } + } + for _, animations := range r.Animations { + animations.Update() + } + r.IdleMonsters.Update() + r.MovingMonsters.Update() + var jumped []geom.Point + for pos, animation := range r.MovingMonsters.Values { + if animation.Frame < 20 { + continue + } + target := r.Level.MonsterTargets[pos] + r.Level.Monsters[target] = r.Level.Monsters[pos] + delete(r.Level.MonsterTargets, pos) + delete(r.Level.Monsters, pos) + jumped = append(jumped, pos) + r.IdleMonsters.Frame(target) + } + for _, pos := range jumped { + delete(r.MovingMonsters.Values, pos) + } + + var jumping []geom.Point + for pos, animation := range r.IdleMonsters.Values { + for animation.Frame > 0 { + if rand.Intn(10) != 0 { + monster, ok := r.Level.Monsters[pos] + if ok && monster != nil { + target, ok := monster.FindTarget(r.Level, pos) + if ok { + r.Level.MonsterTargets[pos] = target + r.MovingMonsters.Frame(pos) + jumping = append(jumping, pos) + break + } + } + } + animation.Frame-- + } + } + for _, pos := range jumping { + delete(r.IdleMonsters.Values, pos) + } + + ctx.Animate() + return false +} + +const defaultAnimationFrames = 20 + +func (r levelController) Render(ctx ui.Context) { + const twelfth = (1. / 6) * geom.Pi + renderer := ctx.Renderer() + + size := geom.Floor32(tins2021.TextureSize * r.Scale) + scale := size / tins2021.TextureSize + + centerTopSquare := geom.PtF32(.5, .5*geom.Sin32(twelfth)) + delta := geom.PtF32(geom.Cos32(twelfth), .5+centerTopSquare.Y).Mul(size) + centerTopSquare = centerTopSquare.Mul(size) + + delta.X = geom.Round32(delta.X) + delta.Y = geom.Round32(delta.Y) + toScreen := func(p geom.Point) geom.PointF32 { + if p.Y%2 == 0 { + return p.ToF32().Mul2D(delta.XY()).Add2D(.5*delta.X, 0) + } + return p.ToF32().Mul2D(delta.XY()) + } + + textures := ctx.Textures() + regular := r.Cubes["regular"].Scaled(textures, scale) + // blocked := r.Cubes["blocked"].Scaled(textures, scale) + // colors := r.Cubes["colored"].Scaled(textures, scale) + + cubeWidth := float32(regular.Normal.Width()) + cubeHeight := float32(regular.Normal.Height()) + + player := ctx.Textures().ScaledByName("dwarf", scale*.6) + star := tins2021.NewAnimatedTexture(ctx.Textures().ScaledByName("star", scale*.4), defaultAnimationFrames) + heart := tins2021.NewAnimatedTexture(ctx.Textures().ScaledByName("heart", scale*.4), defaultAnimationFrames) + monsterTextures := map[tins2021.MonsterType]tins2021.AnimatedTexture{} + for typ, name := range r.MonsterTextureNames { + monsterTextures[typ] = tins2021.NewAnimatedTexture(ctx.Textures().ScaledByName(name, scale*.4), defaultAnimationFrames) + } + propOffset := geom.PtF32(-.5*float32(star.Texture.Height()), -.8*float32(star.Texture.Height())) + + distances := r.Level.Tiles.Distances(r.Level.Player) + + positionOfTile := func(position geom.Point, tile *tins2021.Tile) (topLeft, centerOfPlatform geom.PointF32) { + topLeft = toScreen(position) + if tile.Inversed { + return topLeft, topLeft.Add2D(.5*float32(cubeWidth), .6*float32(cubeHeight)) + } + return topLeft, topLeft.Add2D(.5*float32(cubeWidth), .2*float32(cubeHeight)) + } + + for y := r.Level.Bounds.Min.Y; y < r.Level.Bounds.Max.Y; y++ { + for x := r.Level.Bounds.Min.X; x < r.Level.Bounds.Max.X; x++ { + pos := geom.Pt(x, y) + tile := r.Level.Tiles[pos] + if tile == nil { + continue + } + screenPos, platformPos := positionOfTile(pos, tile) + tileTexture := regular.Normal.Texture + if tile.Inversed { + tileTexture = regular.Inversed.Texture + } + renderer.DrawTexturePoint(tileTexture, screenPos) + r.SmallFont.TextAlign(renderer, platformPos, color.Black, strconv.Itoa(distances[pos]), ui.AlignCenter) + + if tile.Star { + star.Draw(renderer, platformPos.Add(propOffset), r.Animations["star"].Frame(pos)) + } + if tile.Heart { + heart.Draw(renderer, platformPos.Add(propOffset), r.Animations["heart"].Frame(pos)) + } + } + } + + playerPosition := toScreen(r.Level.Player).Sub(geom.Pt(player.Width()/2, player.Height()).ToF32()) + if r.Level.Tiles[r.Level.Player].Inversed { + centerBottomSquare := geom.PtF32(centerTopSquare.X, size-centerTopSquare.Y) + renderer.DrawTexturePoint(player, playerPosition.Add(centerBottomSquare)) + } else { + renderer.DrawTexturePoint(player, playerPosition.Add(centerTopSquare)) + } + + for pos, monsterType := range r.Level.Monsters { + tile := r.Level.Tiles[pos] + if tile == nil { + continue + } + _, platformPos := positionOfTile(pos, tile) + name := r.MonsterTextureNames[monsterType.Type()] + monsterTextures[monsterType.Type()].Draw(renderer, platformPos.Add(propOffset), r.Animations[name].Frame(pos)) + } +} diff --git a/cmd/tins2021/textures.go b/cmd/tins2021/textures.go new file mode 100644 index 0000000..03bb555 --- /dev/null +++ b/cmd/tins2021/textures.go @@ -0,0 +1,53 @@ +package main + +import ( + "image" + + "opslag.de/schobers/geom" + "opslag.de/schobers/tins2021" + "opslag.de/schobers/zntg/ui" +) + +type namedTexture struct { + ui.Texture + + Name string +} + +func newNamedTexture(textures *ui.Textures, name string, im image.Image) namedTexture { + texture, err := textures.CreateTextureGo(name, im, true) + if err != nil { + panic(err) + } + return namedTexture{texture, name} +} + +func (t namedTexture) Scaled(textures *ui.Textures, scale float32) namedTexture { + return namedTexture{textures.ScaledByName(t.Name, scale), t.Name} +} + +type cubeTexture struct { + Normal, Inversed namedTexture +} + +func newCubeTexture(textures *ui.Textures, color string) cubeTexture { + return cubeTexture{ + Normal: newNamedTexture(textures, "cube_"+color, tins2021.GenerateCube(color)), + Inversed: newNamedTexture(textures, "cube_"+color+"_inversed", tins2021.GenerateHole(color)), + } +} + +func (t cubeTexture) Scaled(textures *ui.Textures, scale float32) cubeTexture { + return cubeTexture{ + Normal: t.Normal.Scaled(textures, scale), + Inversed: t.Inversed.Scaled(textures, scale), + } +} + +func newAnimatedTexture(textures *ui.Textures, name string, polygon geom.PolygonF, color string, animation tins2021.AnimationRenderer) tins2021.AnimatedTexture { + texture, err := textures.CreateTextureGo(name, tins2021.AnimatePolygon(polygon, color, animation), true) + if err != nil { + panic(err) + } + return tins2021.NewSquareAnimatedTexture(texture) +} diff --git a/cmd/tins2021/tins2021.go b/cmd/tins2021/tins2021.go index a9784bc..f31644e 100644 --- a/cmd/tins2021/tins2021.go +++ b/cmd/tins2021/tins2021.go @@ -54,9 +54,9 @@ func run() error { if settings.Window.Location != nil { location = &geom.PointF32{X: float32(settings.Window.Location.X), Y: float32(settings.Window.Location.Y)} } - if settings.Window.Size == nil { - settings.Window.Size = ptPtr(800, 600) - } + // if settings.Window.Size == nil { + settings.Window.Size = ptPtr(1024, 768) + // } if settings.Window.VSync == nil { vsync := true settings.Window.VSync = &vsync diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..cda1caa --- /dev/null +++ b/colors.go @@ -0,0 +1,34 @@ +package tins2021 + +import ( + "image/color" + + "github.com/lucasb-eyer/go-colorful" +) + +var Blue = `#499BFF` +var Gray = `#E5E5E5` +var Green = `#9BFF49` +var Orange = `#FF9849` +var Purple = `#9E49FF` +var Red = `#FF4949` +var Yellow = `#FFEF49` + +func Darken(hexColor string, lighten float64) string { return Lighten(hexColor, -lighten) } + +func Lighten(hexColor string, lighten float64) string { + color := mustHexColor(hexColor) + h, c, l := color.Hcl() + lightened := colorful.Hcl(h, c, Clamp(l+lighten)).Clamped() + return lightened.Hex() +} + +func mustHexColor(s string) colorful.Color { + c, err := colorful.Hex(s) + if err != nil { + panic("invalid color") + } + return c +} + +func MustHexColor(s string) color.Color { return mustHexColor(s) } diff --git a/cube.go b/cube.go index 1a39b28..2df8698 100644 --- a/cube.go +++ b/cube.go @@ -9,8 +9,7 @@ import ( "opslag.de/schobers/geom" ) -var Orange = `#FF9849` -var Blue = `#499BFF` +const hexagonRadius = TextureSize / 2 func Clamp(f float64) float64 { if f < 0 { @@ -22,24 +21,19 @@ func Clamp(f float64) float64 { return f } -const hexagonRadius = 128 - -func drawToGC(gc *draw2dimg.GraphicContext, color color.Color, points ...geom.PointF) { - gc.SetStrokeColor(color) - gc.SetFillColor(color) +func drawToGC(gc *draw2dimg.GraphicContext, points ...geom.PointF) { gc.MoveTo(points[0].XY()) for _, p := range points[1:] { gc.LineTo(p.XY()) } gc.Close() - gc.FillStroke() } -func Lighten(hexColor string, lighten float64) string { - color := mustHexColor(hexColor) - h, c, l := color.Hcl() - lightened := colorful.Hcl(h, c, Clamp(l+lighten)).Clamped() - return lightened.Hex() +func fillStrokeToGC(gc *draw2dimg.GraphicContext, color color.Color, points ...geom.PointF) { + gc.SetStrokeColor(color) + gc.SetFillColor(color) + drawToGC(gc, points...) + gc.FillStroke() } func GenerateCube(hexColor string) image.Image { @@ -53,9 +47,9 @@ func GenerateCube(hexColor string) image.Image { center, points := Hexagon(hexagonRadius) gc := draw2dimg.NewGraphicContext(im) - drawToGC(gc, dark, points[2], points[3], points[4], center) - drawToGC(gc, normal, points[4], points[5], points[0], center) - drawToGC(gc, light, points[0], points[1], points[2], center) + fillStrokeToGC(gc, dark, points[2], points[3], points[4], center) + fillStrokeToGC(gc, normal, points[4], points[5], points[0], center) + fillStrokeToGC(gc, light, points[0], points[1], points[2], center) return im } @@ -66,9 +60,9 @@ func GenerateHexagon(hexColor string) image.Image { center, points := Hexagon(hexagonRadius) gc := draw2dimg.NewGraphicContext(im) - drawToGC(gc, color, points[2], points[3], points[4], center) - drawToGC(gc, color, points[4], points[5], points[0], center) - drawToGC(gc, color, points[0], points[1], points[2], center) + fillStrokeToGC(gc, color, points[2], points[3], points[4], center) + fillStrokeToGC(gc, color, points[4], points[5], points[0], center) + fillStrokeToGC(gc, color, points[0], points[1], points[2], center) return im } @@ -94,17 +88,15 @@ func GenerateHole(hexColor string) image.Image { center, points := Hexagon(hexagonRadius) gc := draw2dimg.NewGraphicContext(im) - drawToGC(gc, dark, points[5], points[0], points[1], center) - drawToGC(gc, normal, points[1], points[2], points[3], center) - drawToGC(gc, light, points[3], points[4], points[5], center) + fillStrokeToGC(gc, dark, points[5], points[0], points[1], center) + fillStrokeToGC(gc, normal, points[1], points[2], points[3], center) + fillStrokeToGC(gc, light, points[3], points[4], points[5], center) return im } -func mustHexColor(s string) colorful.Color { - c, err := colorful.Hex(s) - if err != nil { - panic("invalid color") - } - return c +func strokeToGC(gc *draw2dimg.GraphicContext, color color.Color, points ...geom.PointF) { + gc.SetStrokeColor(color) + drawToGC(gc, points...) + gc.Stroke() } diff --git a/level.go b/level.go index b064d03..3172179 100644 --- a/level.go +++ b/level.go @@ -6,66 +6,67 @@ import ( "opslag.de/schobers/geom" ) -type Direction int - -const ( - DirectionDownRight Direction = iota - DirectionDownLeft - DirectionUpLeft - DirectionUpRight -) - type Level struct { - Player geom.Point - Tiles map[geom.Point]*Tile + Player geom.Point + Lives int + StarsCollected int + Tiles Tiles + Monsters Monsters + MonsterTargets map[geom.Point]geom.Point + Bounds geom.Rectangle } -func AdjacentPosition(pos geom.Point, dir Direction) geom.Point { - if pos.Y%2 == 0 { - switch dir { - case DirectionDownRight: - return geom.Pt(pos.X+1, pos.Y+1) - case DirectionDownLeft: - return geom.Pt(pos.X, pos.Y+1) - case DirectionUpLeft: - return geom.Pt(pos.X, pos.Y-1) - case DirectionUpRight: - return geom.Pt(pos.X+1, pos.Y-1) - default: - panic("invalid direction") +func NewLevel() *Level { + const dims = 12 + f := &Level{ + Player: geom.Pt(1, 1), + Lives: 3, + StarsCollected: 0, + Tiles: Tiles{}, + Monsters: Monsters{}, + MonsterTargets: map[geom.Point]geom.Point{}, + Bounds: geom.Rect(1, 1, dims+1, dims+1), + } + for y := 1; y <= dims; y++ { + endX := dims + if y%2 == 0 { + endX-- + } + for x := 1; x <= endX; x++ { + f.Tiles[geom.Pt(x, y)] = &Tile{} } } - switch dir { - case DirectionDownRight: - return geom.Pt(pos.X, pos.Y+1) - case DirectionDownLeft: - return geom.Pt(pos.X-1, pos.Y+1) - case DirectionUpLeft: - return geom.Pt(pos.X-1, pos.Y-1) - case DirectionUpRight: - return geom.Pt(pos.X, pos.Y-1) - default: - panic("invalid direction") - } + return f } func (l Level) CanPlayerMove(dir Direction) (geom.Point, bool) { - towards := AdjacentPosition(l.Player, dir) - from := l.Tiles[l.Player] - to := l.Tiles[towards] - if to == nil { - return geom.ZeroPt, false + return l.Tiles.CanMove(l.Player, dir) +} + +func (l Level) CanMonsterMove(p geom.Point, dir Direction) (geom.Point, bool) { + q, ok := l.Tiles.CanMove(p, dir) + if !ok { + return geom.Point{}, false } - if dir == DirectionDownRight || dir == DirectionDownLeft { - if !from.Inversed && to.Inversed { - return geom.ZeroPt, false - } - } else { - if from.Inversed && !to.Inversed { - return geom.ZeroPt, false + if l.CanMonsterMoveTo(q) { + return q, true + } + return geom.Point{}, false +} + +func (l Level) CanMonsterMoveTo(p geom.Point) bool { + if l.Tiles[p].Occupied() { + return false + } + if _, ok := l.Monsters[p]; ok { + return false + } + for _, target := range l.MonsterTargets { + if p == target { + return false } } - return towards, true + return true } func (l *Level) MovePlayer(dir Direction) bool { @@ -73,30 +74,92 @@ func (l *Level) MovePlayer(dir Direction) bool { if !allowed { return false } - l.Tiles[l.Player].Invert() l.Player = towards + tile := l.Tiles[towards] + if tile.Heart { + l.Lives++ + tile.Heart = false + } + if tile.Star { + l.StarsCollected++ + tile.Star = false + } return true } -func NewRandomLevel() *Level { - f := &Level{ - Tiles: map[geom.Point]*Tile{}, - Player: geom.Pt(1, 1), +func (l *Level) Randomize(difficulty int, stars int) { + if difficulty < 0 { + difficulty = 0 } - for y := 1; y <= 10; y++ { - endX := 10 - if y%2 == 0 { - endX-- - } - for x := 1; x <= endX; x++ { - f.Tiles[geom.Pt(x, y)] = &Tile{Inversed: rand.Intn(6) == 0} + positions := make([]geom.Point, 0, len(l.Tiles)) + for pos := range l.Tiles { + positions = append(positions, pos) + } + flip := difficulty * len(l.Tiles) / 200 + if flip > len(l.Tiles)/2 { + flip = len(l.Tiles) / 2 + } + for ; flip > 0; flip-- { + for { + i := rand.Intn(len(positions)) + pos := positions[i] + if l.Tiles[pos].Inversed { + continue + } + l.Tiles[pos].Invert() + if l.Tiles.AllReachable(l.Player) { + break + } + l.Tiles[pos].Invert() } } - return f + for stars > 0 { + i := rand.Intn(len(positions)) + pos := positions[i] + if l.Tiles[pos].Occupied() { + continue + } + l.Tiles[pos].Star = true + stars-- + } + hearts := 1 + (80-difficulty)*4/80 // [5..0] + for hearts > 0 { + i := rand.Intn(len(positions)) + pos := positions[i] + if l.Tiles[pos].Occupied() { + continue + } + l.Tiles[pos].Heart = true + hearts-- + } + monsters := 1 + (4 * difficulty / 100) + minRandomMonster := (100 - difficulty) + minChaserMonster := (200 - difficulty) / 2 + for monsters > 0 { + i := rand.Intn(len(positions)) + pos := positions[i] + curr := l.Monsters[pos] + if l.Tiles[pos].Occupied() || curr != nil { + continue + } + monster := MonsterTypeStraight + m := rand.Intn(100) + if m >= minChaserMonster { + monster = MonsterTypeChaser + } else if m >= minRandomMonster { + monster = MonsterTypeRandom + } + switch monster { + case MonsterTypeStraight: + l.Monsters[pos] = &StraightWalkingMonster{Direction: RandomDirection()} + case MonsterTypeRandom: + l.Monsters[pos] = &RandomWalkingMonster{} + case MonsterTypeChaser: + l.Monsters[pos] = &ChasingMonster{} + default: + panic("not implemented") + // l.Monsters[pos] = monster + } + monsters-- + } } - -type Tile struct { - Inversed bool -} - -func (t *Tile) Invert() { t.Inversed = !t.Inversed } diff --git a/level_test.go b/level_test.go new file mode 100644 index 0000000..8380ff0 --- /dev/null +++ b/level_test.go @@ -0,0 +1,31 @@ +package tins2021 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLevelIsAllReachable(t *testing.T) { + level := NewLevel() + assert.True(t, level.Tiles.AllReachable(level.Player)) +} + +func TestRandomizedLevelIsAllReachable(t *testing.T) { + level := NewLevel() + level.Randomize(len(level.Tiles)/2, 0) + assert.True(t, level.Tiles.AllReachable(level.Player)) +} + +func BenchmarkRandomizedLevel(b *testing.B) { + for i := 0; i < b.N; i++ { + level := NewLevel() + level.Randomize(len(level.Tiles)*50/100, 0) + } +} + +func BenchmarkNewLevel(b *testing.B) { + for i := 0; i < b.N; i++ { + NewLevel() + } +} diff --git a/monsters.go b/monsters.go new file mode 100644 index 0000000..a2e672c --- /dev/null +++ b/monsters.go @@ -0,0 +1,67 @@ +package tins2021 + +import ( + "opslag.de/schobers/geom" +) + +type ChasingMonster struct{} + +func (m ChasingMonster) Type() MonsterType { return MonsterTypeChaser } +func (m *ChasingMonster) FindTarget(level *Level, src geom.Point) (geom.Point, bool) { + path := level.Tiles.ShortestPath(src, level.Player, func(_ geom.Point, t *Tile) bool { return !t.Occupied() }) + if len(path) < 2 { + return geom.Point{}, false + } + if level.CanMonsterMoveTo(path[1]) { + return path[1], true + } + return geom.Point{}, false +} + +type Monster interface { + Type() MonsterType + FindTarget(*Level, geom.Point) (geom.Point, bool) +} + +type Monsters map[geom.Point]Monster + +type MonsterType int + +const ( + MonsterTypeStraight MonsterType = iota + MonsterTypeRandom + MonsterTypeChaser +) + +type RandomWalkingMonster struct{} + +func (m RandomWalkingMonster) Type() MonsterType { return MonsterTypeRandom } +func (m *RandomWalkingMonster) FindTarget(level *Level, src geom.Point) (geom.Point, bool) { + for i := 0; i < 5; i++ { + dir := RandomDirection() + dst, ok := level.CanMonsterMove(src, dir) + if ok { + return dst, true + } + } + return geom.Point{}, false +} + +type StraightWalkingMonster struct { + Direction Direction +} + +func (m StraightWalkingMonster) Type() MonsterType { return MonsterTypeStraight } +func (m *StraightWalkingMonster) FindTarget(level *Level, src geom.Point) (geom.Point, bool) { + dst, ok := level.CanMonsterMove(src, m.Direction) + if ok { + return dst, true + } + reverse := m.Direction.Reverse() + dst, ok = level.CanMonsterMove(src, reverse) + if ok { + m.Direction = reverse + return dst, true + } + return geom.Point{}, false +} diff --git a/rendereranimation.go b/rendereranimation.go new file mode 100644 index 0000000..6f1d854 --- /dev/null +++ b/rendereranimation.go @@ -0,0 +1,211 @@ +package tins2021 + +import ( + "fmt" + "image" + "os" + + "github.com/fogleman/fauxgl" + "github.com/nfnt/resize" + "golang.org/x/image/draw" + "opslag.de/schobers/geom" + "opslag.de/schobers/zntg/ui" +) + +const ( + fovy = 40 // vertical field of view in degrees + near = 1 // near clipping plane + far = 10 // far clipping plane +) + +var ( + eye = fauxgl.V(0, 0, 4) // camera position + center = fauxgl.V(0, 0, 0) // view center position + up = fauxgl.V(0, 1, 0) // up vector + light = fauxgl.V(.5, 1, .75).Normalize() // light direction +) + +func AnimatePolygon(polygon geom.PolygonF, hexColor string, renderer AnimationRenderer) image.Image { + mesh := generateMeshFromPolygon(polygon, .2) + renderer.setup(mesh) + return renderMeshAnimation(hexColor, renderer.frames(), renderer.render) +} + +func AnimateSTL(resources ui.PhysicalResources, name, hexColor string, renderer AnimationRenderer) image.Image { + path, err := resources.FetchResource(name) + if err != nil { + panic(err) + } + mesh, err := fauxgl.LoadSTL(path) + if err != nil { + panic(err) + } + renderer.setup(mesh) + return renderMeshAnimation(hexColor, renderer.frames(), renderer.render) +} + +type animationRendererBase struct { + Frames int + Mesh *fauxgl.Mesh +} + +func (r animationRendererBase) frames() int { return r.Frames } + +func (r *animationRendererBase) setup(mesh *fauxgl.Mesh) { + r.Mesh = mesh + mesh.BiUnitCube() +} + +var _ AnimationRenderer = &RotateAnimationRenderer{} +var _ AnimationRenderer = &WobbleAnimationRenderer{} + +type AnimationRenderer interface { + frames() int + setup(*fauxgl.Mesh) + render(*fauxgl.Context, int, float64) +} + +func generateMeshFromPolygon(polygon geom.PolygonF, thickness float64) *fauxgl.Mesh { + vec := func(p geom.PointF, z float64) fauxgl.Vector { return fauxgl.V(p.X, p.Y, z) } + tri := fauxgl.NewTriangleForPoints + face := func(q, r, s geom.PointF, n float64) *fauxgl.Triangle { + return tri(vec(q, n*thickness), vec(r, n*thickness), vec(s, n*thickness)) + } + var triangles []*fauxgl.Triangle + // generate front & back + for _, t := range polygon.Triangulate() { + triangles = append(triangles, + face(t.Points[0], t.Points[1], t.Points[2], 1), // front + face(t.Points[2], t.Points[1], t.Points[0], -1), // back + ) + } + // generate side + back, front := -thickness, thickness + for i, p := range polygon.Points { + next := polygon.Points[(i+1)%len(polygon.Points)] + q, r, s, t := vec(p, back), vec(next, back), vec(next, front), vec(p, front) + triangles = append(triangles, tri(q, r, s), tri(q, s, t)) + } + + mesh := fauxgl.NewTriangleMesh(triangles) + return mesh +} + +func renderMeshAnimation(hexColor string, frames int, render func(*fauxgl.Context, int, float64)) image.Image { + const scale = 4 + context := fauxgl.NewContext(TextureSize*scale, TextureSize*scale) + + // matrix := fauxgl.LookAt(eye, center, up).Perspective(fovy, 1, near, far) + + const s = 1.1 + // rot3 := func(m fauxgl.Matrix) fauxgl.Matrix { + // return fauxgl.Matrix{ + // X00: m.X20, X01: m.X10, X02: m.X00, X03: m.X03, + // X10: m.X21, X11: m.X11, X12: m.X01, X13: m.X13, + // X20: m.X22, X21: m.X12, X22: m.X02, X23: m.X23, + // X30: m.X30, X31: m.X31, X32: m.X32, X33: m.X33, + // } + // } + + // sqrt_6_1 := 1 / geom.Sqrt(6) + // iso := fauxgl.Matrix{ + // X00: sqrt_6_1 * geom.Sqrt(3), X01: 0, X02: -sqrt_6_1 * geom.Sqrt(3), X03: 0, + // X10: sqrt_6_1, X11: 2 * sqrt_6_1, X12: sqrt_6_1, X13: 0, + // X20: sqrt_6_1 * geom.Sqrt(2), X21: -sqrt_6_1 * geom.Sqrt(2), X22: sqrt_6_1 * geom.Sqrt(2), X23: 0, + // X30: 0, X31: 0, X32: 0, X33: 1} + + matrix := fauxgl.Orthographic(-s, s, -s, s, near, far).Mul(fauxgl.LookAt(eye, center, up)) + + color := fauxgl.HexColor(hexColor) + + animation := image.NewNRGBA(image.Rect(0, 0, TextureSize*frames, TextureSize)) + for i := 0; i < frames; i++ { + context.ClearDepthBuffer() + context.ClearColorBufferWith(fauxgl.Transparent) + + shader := fauxgl.NewPhongShader(matrix, light, eye) + shader.ObjectColor = color + shader.AmbientColor = fauxgl.MakeColor(mustHexColor(`#7F7F7F`)) + context.Shader = shader + + render(context, i, float64(i)/float64(frames)) + + frame := resize.Resize(TextureSize, TextureSize, context.Image(), resize.Bilinear) + draw.Copy(animation, image.Pt(i*TextureSize, 0), frame, frame.Bounds(), draw.Src, nil) + } + return animation +} + +type RotateAnimationRenderer struct { + animationRendererBase + + Rotation float64 +} + +func NewRotateAnimation(frames int) AnimationRenderer { + return &RotateAnimationRenderer{ + animationRendererBase: animationRendererBase{Frames: frames}, + Rotation: 2 * geom.Pi / float64(frames), + } +} + +func (a RotateAnimationRenderer) render(context *fauxgl.Context, _ int, _ float64) { + context.DrawMesh(a.Mesh) + a.Mesh.Transform(fauxgl.Rotate(up, a.Rotation)) +} + +func saveMeshSTL(path, name string, mesh *fauxgl.Mesh) error { + stl, err := os.Create(path) + if err != nil { + return err + } + defer stl.Close() + + fmt.Fprintf(stl, "solid %s\n", name) + for _, triangle := range mesh.Triangles { + normal := triangle.Normal() + fmt.Fprintf(stl, " facet normal %f, %f, %f\n", normal.X, normal.Y, normal.Z) + fmt.Fprintf(stl, " outer loop\n") + fmt.Fprintf(stl, " vertex %f, %f, %f\n", triangle.V1.Position.X, triangle.V1.Position.Y, triangle.V1.Position.Z) + fmt.Fprintf(stl, " vertex %f, %f, %f\n", triangle.V2.Position.X, triangle.V2.Position.Y, triangle.V2.Position.Z) + fmt.Fprintf(stl, " vertex %f, %f, %f\n", triangle.V3.Position.X, triangle.V3.Position.Y, triangle.V3.Position.Z) + fmt.Fprintf(stl, " endloop\n") + fmt.Fprintf(stl, " endfacet\n") + } + fmt.Fprintf(stl, "endsolid %s\n", name) + return nil +} + +func SaveSTLFromPolygon(path, name string, polygon geom.PolygonF, thickness float64) { + mesh := generateMeshFromPolygon(polygon, thickness) + saveMeshSTL(path, name, mesh) +} + +type WobbleAnimationRenderer struct { + animationRendererBase + + Wobble float64 +} + +func NewWobbleAnimation(frames int, wobble float64) AnimationRenderer { + return &WobbleAnimationRenderer{ + animationRendererBase: animationRendererBase{Frames: frames}, + Wobble: wobble, + } +} + +func (a WobbleAnimationRenderer) animate(frame float64) float64 { + frame += .25 + if frame >= 1 { + frame -= 1 + } + // return geom.Cos(float64(frame) * 2 * geom.Pi / float64(a.Frames)) + return geom.Abs(frame*4-2) - 1 +} + +func (a WobbleAnimationRenderer) render(context *fauxgl.Context, frame int, animation float64) { + context.DrawMesh(a.Mesh) + curr := a.animate(animation) + next := a.animate(float64(frame+1) / float64(a.Frames)) + a.Mesh.Transform(fauxgl.Rotate(up, (next-curr)*a.Wobble*geom.Pi/180)) +} diff --git a/scenery.go b/scenery.go new file mode 100644 index 0000000..25ad7bd --- /dev/null +++ b/scenery.go @@ -0,0 +1,94 @@ +package tins2021 + +import ( + "image" + + "github.com/llgcode/draw2d/draw2dimg" + "opslag.de/schobers/geom" +) + +const TextureSize = 128 + +func CreateHeart() geom.PolygonF { + var polygon geom.PolygonF + const segments = 100 + for segment := 0; segment < 100; segment++ { + t := 2 * geom.Pi * float64(segment) / segments + st := geom.Sin(t) + polygon.Points = append(polygon.Points, geom.PtF( + 16*st*st*st, + 13*geom.Cos(t)-5*geom.Cos(2*t)-2*geom.Cos(3*t)-geom.Cos(4*t))) + } + return polygon.Reverse().Mul(1. / 16) +} + +func CreateHexagon() geom.PolygonF { + var polygon geom.PolygonF + pt := func(rotation float64) geom.PointF { + a := .5*geom.Pi + 2*geom.Pi*rotation + return geom.PtF(geom.Cos(a), geom.Sin(a)) + } + const sides = 6 + for side := 0; side < 6; side++ { + polygon.Points = append(polygon.Points, + pt(float64(side)/float64(sides)), + ) + } + return polygon +} + +func CreateStar(sides int) geom.PolygonF { + var polygon geom.PolygonF + pt := func(rotation float64) geom.PointF { + a := .5*geom.Pi + 2*geom.Pi*rotation + return geom.PtF(geom.Cos(a), geom.Sin(a)) + } + for side := 0; side < sides; side++ { + polygon.Points = append(polygon.Points, + pt(float64(side)/float64(sides)), + pt((float64(side)+0.5)/float64(sides)).Mul(.5), + ) + } + return polygon +} + +func RenderPolygon2D(polygon geom.PolygonF, hexColor string) image.Image { + im := image.NewRGBA(image.Rect(0, 0, TextureSize, TextureSize)) + color := mustHexColor(hexColor) + gc := draw2dimg.NewGraphicContext(im) + fillStrokeToGC(gc, color, polygon.Points...) + return im +} + +func RenderTriangles2D(triangles []geom.TriangleF, hexColor string) image.Image { + im := image.NewRGBA(image.Rect(0, 0, TextureSize, TextureSize)) + color := mustHexColor(hexColor) + gc := draw2dimg.NewGraphicContext(im) + for _, triangle := range triangles { + strokeToGC(gc, color, triangle.Points[:]...) + } + return im +} + +func RenderTriangleSides2D(triangles []geom.TriangleF) image.Image { + im := image.NewRGBA(image.Rect(0, 0, TextureSize, TextureSize)) + r, g, b := mustHexColor(`#FF0000`), mustHexColor(`#00FF00`), mustHexColor(`#0000FF`) + gc := draw2dimg.NewGraphicContext(im) + gc.SetLineWidth(2) + for _, triangle := range triangles { + inset := triangle.Inset(5) + gc.SetStrokeColor(r) + gc.MoveTo(inset.Points[0].XY()) + gc.LineTo(inset.Points[1].XY()) + gc.Stroke() + gc.SetStrokeColor(g) + gc.MoveTo(inset.Points[1].XY()) + gc.LineTo(inset.Points[2].XY()) + gc.Stroke() + gc.SetStrokeColor(b) + gc.MoveTo(inset.Points[2].XY()) + gc.LineTo(inset.Points[0].XY()) + gc.Stroke() + } + return im +} diff --git a/tiles.go b/tiles.go new file mode 100644 index 0000000..f3f3e5a --- /dev/null +++ b/tiles.go @@ -0,0 +1,197 @@ +package tins2021 + +import ( + "math" + "math/rand" + + "opslag.de/schobers/geom" +) + +var AllDirections = []Direction{DirectionDownRight, DirectionDownLeft, DirectionUpLeft, DirectionUpRight} + +func RandomDirection() Direction { + return AllDirections[rand.Intn(len(AllDirections))] +} + +func AdjacentPosition(pos geom.Point, dir Direction) geom.Point { + if pos.Y%2 == 0 { + switch dir { + case DirectionDownRight: + return geom.Pt(pos.X+1, pos.Y+1) + case DirectionDownLeft: + return geom.Pt(pos.X, pos.Y+1) + case DirectionUpLeft: + return geom.Pt(pos.X, pos.Y-1) + case DirectionUpRight: + return geom.Pt(pos.X+1, pos.Y-1) + default: + panic("invalid direction") + } + } + switch dir { + case DirectionDownRight: + return geom.Pt(pos.X, pos.Y+1) + case DirectionDownLeft: + return geom.Pt(pos.X-1, pos.Y+1) + case DirectionUpLeft: + return geom.Pt(pos.X-1, pos.Y-1) + case DirectionUpRight: + return geom.Pt(pos.X, pos.Y-1) + default: + panic("invalid direction") + } +} + +type Direction int + +const ( + DirectionDownRight Direction = iota + DirectionDownLeft + DirectionUpLeft + DirectionUpRight +) + +func (d Direction) Reverse() Direction { + switch d { + case DirectionDownRight: + return DirectionUpLeft + case DirectionDownLeft: + return DirectionUpRight + case DirectionUpLeft: + return DirectionDownRight + case DirectionUpRight: + return DirectionDownLeft + default: + panic("direction not supported") + } +} + +type Tile struct { + Inversed bool + Star bool + Heart bool +} + +func (t *Tile) Occupied() bool { + return t.Star || t.Heart +} + +func (t *Tile) Invert() { t.Inversed = !t.Inversed } + +type Tiles map[geom.Point]*Tile + +func (t Tiles) AllReachable(from geom.Point) bool { + visited := map[geom.Point]bool{} + visit := []geom.Point{from} + for len(visit) > 0 { + next := visit[0] + visit = visit[1:] + for _, dir := range AllDirections { + neighbour, ok := t.CanMove(next, dir) + if !ok || visited[neighbour] { + continue + } + visited[neighbour] = true + visit = append(visit, neighbour) + } + } + return len(visited) == len(t) +} + +func (t Tiles) CanMove(p geom.Point, dir Direction) (geom.Point, bool) { + towards := AdjacentPosition(p, dir) + from := t[p] + to := t[towards] + if to == nil { + return geom.Point{}, false + } + if dir == DirectionDownRight || dir == DirectionDownLeft { + if !from.Inversed && to.Inversed { + return geom.Point{}, false + } + } else { + if from.Inversed && !to.Inversed { + return geom.Point{}, false + } + } + return towards, true +} + +func (t Tiles) CanMoveTile(p geom.Point, dir Direction) (geom.Point, *Tile, bool) { + to, ok := t.CanMove(p, dir) + if !ok { + return geom.Point{}, nil, false + } + return to, t[to], true +} + +func (t Tiles) Distances(from geom.Point) map[geom.Point]int { + distance := map[geom.Point]int{ + from: 0, + } + visit := []geom.Point{from} + for len(visit) > 0 { + next := visit[0] + visit = visit[1:] + for _, dir := range AllDirections { + neighbour, ok := t.CanMove(next, dir) + if !ok { + continue + } + d := distance[next] + 1 + if neighbourD, ok := distance[neighbour]; ok && neighbourD <= d { + continue + } + distance[neighbour] = d + visit = append(visit, neighbour) + } + } + return distance +} + +func (t Tiles) ShortestPath(from, to geom.Point, canMoveTo func(geom.Point, *Tile) bool) []geom.Point { + distances := map[geom.Point]int{ + from: 0, + } + origins := map[geom.Point]geom.Point{ + from: from, + } + estimated := map[geom.Point]int{ + from: from.DistInt(to), + } + visit := map[geom.Point]bool{from: true} + for len(visit) > 0 { + var next geom.Point + best := math.MaxInt32 + for candidate := range visit { + e := estimated[candidate] + if e < best { + next = candidate + best = e + } + } + if next == to { + path := []geom.Point{to} + for path[0] != from { + path = append([]geom.Point{origins[path[0]]}, path...) + } + return path + } + delete(visit, next) + for _, dir := range AllDirections { + neighbour, ok := t.CanMove(next, dir) + if !ok || (canMoveTo != nil && !canMoveTo(neighbour, t[neighbour])) { + continue + } + d := distances[next] + 1 + if neighbourD, ok := distances[neighbour]; ok && neighbourD <= d { + continue + } + distances[neighbour] = d + origins[neighbour] = next + estimated[neighbour] = d + neighbour.DistInt(to) + visit[neighbour] = true + } + } + return []geom.Point{} +} diff --git a/tiles_test.go b/tiles_test.go new file mode 100644 index 0000000..c655b92 --- /dev/null +++ b/tiles_test.go @@ -0,0 +1,89 @@ +package tins2021 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "opslag.de/schobers/geom" +) + +func TestTilesNotTraversableWhenNoTile(t *testing.T) { + tiles := Tiles{} + pos := geom.ZeroPt + tiles[pos] = &Tile{} + for _, dir := range AllDirections { + _, ok := tiles.CanMove(pos, dir) + assert.False(t, ok) + } +} + +func TestTilesIsTraversableInDirection(t *testing.T) { + tiles := Tiles{} + center := geom.Pt(1, 1) + tiles[center] = &Tile{} + for _, dir := range AllDirections { + tiles[AdjacentPosition(center, dir)] = &Tile{} + } + tiles[geom.Pt(1, 2)] = &Tile{} + for _, dir := range AllDirections { + pos, ok := tiles.CanMove(center, dir) + assert.True(t, ok) + assert.Equal(t, AdjacentPosition(center, dir), pos) + } +} + +func TestTilesNotTraversableWhenGoingUpFromInversedTile(t *testing.T) { + tiles := Tiles{} + center := geom.Pt(1, 1) + tiles[center] = &Tile{Inversed: true} + leftUp := AdjacentPosition(center, DirectionUpLeft) + rightUp := AdjacentPosition(center, DirectionUpRight) + tiles[leftUp] = &Tile{} + tiles[rightUp] = &Tile{} + _, ok := tiles.CanMove(center, DirectionUpLeft) + assert.False(t, ok) + _, ok = tiles.CanMove(center, DirectionUpLeft) + assert.False(t, ok) +} + +func TestTilesNotTraversableWhenGoingDownToInversedTile(t *testing.T) { + tiles := Tiles{} + center := geom.Pt(1, 1) + tiles[center] = &Tile{} + leftDown := AdjacentPosition(center, DirectionDownLeft) + rightDown := AdjacentPosition(center, DirectionDownRight) + tiles[leftDown] = &Tile{Inversed: true} + tiles[rightDown] = &Tile{Inversed: true} + _, ok := tiles.CanMove(center, DirectionDownLeft) + assert.False(t, ok) + _, ok = tiles.CanMove(center, DirectionDownLeft) + assert.False(t, ok) +} + +func TestTilesTraversableWhenGoingDownFromInversedTile(t *testing.T) { + tiles := Tiles{} + center := geom.Pt(1, 1) + tiles[center] = &Tile{Inversed: true} + leftDown := AdjacentPosition(center, DirectionDownLeft) + rightDown := AdjacentPosition(center, DirectionDownRight) + tiles[leftDown] = &Tile{} + tiles[rightDown] = &Tile{} + _, ok := tiles.CanMove(center, DirectionDownLeft) + assert.True(t, ok) + _, ok = tiles.CanMove(center, DirectionDownLeft) + assert.True(t, ok) +} + +func TestTilesTraversableWhenGoingUpToInversedTile(t *testing.T) { + tiles := Tiles{} + center := geom.Pt(1, 1) + tiles[center] = &Tile{} + leftUp := AdjacentPosition(center, DirectionUpLeft) + rightUp := AdjacentPosition(center, DirectionUpRight) + tiles[leftUp] = &Tile{Inversed: true} + tiles[rightUp] = &Tile{Inversed: true} + _, ok := tiles.CanMove(center, DirectionUpLeft) + assert.True(t, ok) + _, ok = tiles.CanMove(center, DirectionUpLeft) + assert.True(t, ok) +}