diff --git a/assets/creeps/bat/LICENSE b/assets/creeps/bat/LICENSE new file mode 100644 index 0000000..bfcce14 --- /dev/null +++ b/assets/creeps/bat/LICENSE @@ -0,0 +1,3 @@ +These sprites were created by Penusbmic. + +https://penusbmic.itch.io/dungeon-enemy-pack-3 diff --git a/assets/creeps/vampire/LICENSE b/assets/creeps/vampire/LICENSE new file mode 100644 index 0000000..83335e1 --- /dev/null +++ b/assets/creeps/vampire/LICENSE @@ -0,0 +1,3 @@ +These sprites were created by Chasersgaming. + +https://chasersgaming.itch.io/rpg-characer-vampire-nes diff --git a/assets/creeps/vampire1.png b/assets/creeps/vampire/vampire1.png similarity index 100% rename from assets/creeps/vampire1.png rename to assets/creeps/vampire/vampire1.png diff --git a/assets/creeps/vampire2.png b/assets/creeps/vampire/vampire2.png similarity index 100% rename from assets/creeps/vampire2.png rename to assets/creeps/vampire/vampire2.png diff --git a/assets/creeps/vampire3.png b/assets/creeps/vampire/vampire3.png similarity index 100% rename from assets/creeps/vampire3.png rename to assets/creeps/vampire/vampire3.png diff --git a/assets/ojas-dungeon/LICENSE b/assets/ojas-dungeon/LICENSE new file mode 100644 index 0000000..358ec6f --- /dev/null +++ b/assets/ojas-dungeon/LICENSE @@ -0,0 +1,3 @@ +These tilesets were created by Ojas Sabadra. + +https://ojas-sabadra.itch.io/dungeon-tileset-i diff --git a/assets/sandstone-dungeon/LICENSE b/assets/sandstone-dungeon/LICENSE new file mode 100644 index 0000000..0aba397 --- /dev/null +++ b/assets/sandstone-dungeon/LICENSE @@ -0,0 +1,3 @@ +These tilesets were created by Kartoy. + +https://kartoy.itch.io/32x32sandstone-dungeon-and-character-pack diff --git a/audio.go b/audio.go index 5c2a456..765082a 100644 --- a/audio.go +++ b/audio.go @@ -1,6 +1,8 @@ package main import ( + "log" + "github.com/hajimehoshi/ebiten/v2/audio" "github.com/hajimehoshi/ebiten/v2/audio/wav" ) @@ -16,7 +18,18 @@ const ( SoundGib ) -const numSounds = 7 // Must match above size. +var soundMap = map[int]string{ + SoundGunshot: "assets/audio/gunshot.wav", + SoundVampireDie1: "assets/audio/vampiredie1.wav", + SoundVampireDie2: "assets/audio/vampiredie2.wav", + SoundBat: "assets/audio/bat.wav", + SoundPlayerHurt: "assets/audio/playerhurt.wav", + SoundPlayerDie: "assets/audio/playerdie.wav", + SoundMunch: "assets/audio/munch.wav", +} +var soundAtlas [][]*audio.Player + +var nextSound = make([]int, len(soundMap)) func loadWav(context *audio.Context, p string) (*audio.Player, error) { f, err := assetsFS.Open(p) @@ -32,3 +45,33 @@ func loadWav(context *audio.Context, p string) (*audio.Player, error) { return context.NewPlayer(stream) } + +func loadStream(ctx *audio.Context, p string) (*audio.Player, error) { + stream, err := loadWav(ctx, p) + if err != nil { + return nil, err + } + + // Workaround to prevent delays when playing for the first time. + stream.SetVolume(0) + stream.Play() + stream.Pause() + stream.Rewind() + + return stream, nil +} + +func loadSoundAtlas(ctx *audio.Context) [][]*audio.Player { + atlas := make([][]*audio.Player, len(soundMap)) + var err error + for soundID, soundPath := range soundMap { + atlas[soundID] = make([]*audio.Player, 4) + for i := 0; i < 4; i++ { + atlas[soundID][i], err = loadStream(ctx, soundPath) + if err != nil { + log.Fatal(err) + } + } + } + return atlas +} diff --git a/debug.go b/debug.go new file mode 100644 index 0000000..9669b8b --- /dev/null +++ b/debug.go @@ -0,0 +1,15 @@ +//go:build debug + +package main + +import ( + "log" + "net/http" + _ "net/http/pprof" +) + +func init() { + go func() { + log.Fatal(http.ListenAndServe("localhost:8080", nil)) + }() +} diff --git a/game.go b/game.go index 7febe5c..ad15c91 100644 --- a/game.go +++ b/game.go @@ -21,9 +21,6 @@ import ( "golang.org/x/text/message" ) -var bulletImage *ebiten.Image -var flashImage *ebiten.Image - var numberPrinter = message.NewPrinter(language.English) var colorBlood = color.RGBA{102, 0, 0, 255} @@ -79,8 +76,13 @@ type game struct { w, h int level *Level + levelNum int + player *gamePlayer + requiredSouls int + spawnedPortal bool + gameStartTime time.Time gameOverTime time.Time @@ -96,19 +98,10 @@ type game struct { ojasSS *PlayerSpriteSheet - heartImg *ebiten.Image - vampireImage1 *ebiten.Image - vampireImage2 *ebiten.Image - vampireImage3 *ebiten.Image - garlicImage *ebiten.Image - holyWaterImage *ebiten.Image - overlayImg *ebiten.Image op *ebiten.DrawImageOptions audioContext *audio.Context - nextSound []int - soundBuffer [][]*audio.Player lastBatSound time.Time @@ -123,10 +116,11 @@ type game struct { flashMessageText string flashMessageUntil time.Time - godMode bool - noclipMode bool - debugMode bool - cpuProfile *os.File + godMode bool + noclipMode bool + debugMode bool + fullBrightMode bool + cpuProfile *os.File } const sampleRate = 44100 @@ -134,15 +128,13 @@ const sampleRate = 44100 // NewGame returns a new isometric demo game. func NewGame() (*game, error) { g := &game{ - camScale: 2, - camScaleTo: 2, - mousePanX: math.MinInt32, - mousePanY: math.MinInt32, - op: &ebiten.DrawImageOptions{}, - - soundBuffer: make([][]*audio.Player, numSounds), - nextSound: make([]int, numSounds), + camScale: 2, + camScaleTo: 2, + mousePanX: math.MinInt32, + mousePanY: math.MinInt32, activeGamepad: -1, + + op: &ebiten.DrawImageOptions{}, } g.audioContext = audio.NewContext(sampleRate) @@ -185,134 +177,15 @@ func (g *game) loadAssets() error { return fmt.Errorf("failed to load embedded spritesheet: %s", err) } - f, err := assetsFS.Open("assets/weapons/bullet.png") - if err != nil { - return err - } - img, _, err := image.Decode(f) - if err != nil { - return err - } - bulletImage = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/weapons/flash.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - flashImage = ebiten.NewImageFromImage(img) - - g.soundBuffer[SoundGunshot] = make([]*audio.Player, 4) - g.soundBuffer[SoundVampireDie1] = make([]*audio.Player, 4) - g.soundBuffer[SoundVampireDie2] = make([]*audio.Player, 4) - g.soundBuffer[SoundBat] = make([]*audio.Player, 4) - g.soundBuffer[SoundPlayerHurt] = make([]*audio.Player, 4) - g.soundBuffer[SoundPlayerDie] = make([]*audio.Player, 4) - g.soundBuffer[SoundMunch] = make([]*audio.Player, 4) - - loadStream := func(p string) (*audio.Player, error) { - stream, err := loadWav(g.audioContext, p) - if err != nil { - return nil, err - } - - // Workaround to prevent delays when playing for the first time. - stream.SetVolume(0) - stream.Play() - stream.Pause() - stream.Rewind() - - return stream, nil - } - - soundMap := map[int]string{ - SoundGunshot: "assets/audio/gunshot.wav", - SoundVampireDie1: "assets/audio/vampiredie1.wav", - SoundVampireDie2: "assets/audio/vampiredie2.wav", - SoundBat: "assets/audio/bat.wav", - SoundPlayerHurt: "assets/audio/playerhurt.wav", - SoundPlayerDie: "assets/audio/playerdie.wav", - SoundMunch: "assets/audio/munch.wav", - } - for i := 0; i < 4; i++ { - for soundID, soundPath := range soundMap { - g.soundBuffer[soundID][i], err = loadStream(soundPath) - if err != nil { - return err - } - } - } - - f, err = assetsFS.Open("assets/creeps/vampire1.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.vampireImage1 = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/creeps/vampire2.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.vampireImage2 = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/creeps/vampire3.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.vampireImage3 = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/items/garlic.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.garlicImage = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/items/holywater.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.holyWaterImage = ebiten.NewImageFromImage(img) - - f, err = assetsFS.Open("assets/ui/heart.png") - if err != nil { - return err - } - img, _, err = image.Decode(f) - if err != nil { - return err - } - g.heartImg = ebiten.NewImageFromImage(img) + soundAtlas = loadSoundAtlas(g.audioContext) return nil } func (g *game) newItem(itemType int) *gameItem { - sprite := g.garlicImage + sprite := imageAtlas[ImageGarlic] if itemType == itemTypeHolyWater { - sprite = g.holyWaterImage + sprite = imageAtlas[ImageHolyWater] } x, y := g.level.newSpawnLocation() return &gameItem{ @@ -328,10 +201,10 @@ func (g *game) newItem(itemType int) *gameItem { func (g *game) newCreep(creepType int) *gameCreep { sprites := []*ebiten.Image{ - g.vampireImage1, - g.vampireImage2, - g.vampireImage3, - g.vampireImage2, + imageAtlas[ImageVampire1], + imageAtlas[ImageVampire2], + imageAtlas[ImageVampire3], + imageAtlas[ImageVampire2], } if creepType == TypeBat { sprites = []*ebiten.Image{ @@ -364,37 +237,40 @@ func (g *game) newCreep(creepType int) *gameCreep { } } -func (g *game) reset() error { - log.Println("Starting a new game") +func (g *game) nextLevel() error { + g.levelNum++ + if g.levelNum > 13 { + log.Fatal("YOU WIN") + } + return g.generateLevel() +} - g.tick = 0 +func (g *game) generateLevel() error { + // Remove projectiles. + g.projectiles = nil + + // Remove creeps. + if g.level != nil { + g.level.creeps = nil + } var err error - g.level, err = NewLevel() + g.level, err = NewLevel(g.levelNum) if err != nil { return fmt.Errorf("failed to create new level: %s", err) } g.level.player = g.player - // Reset player score. - g.player.score = 0 - - // Reset player health. - g.player.health = 3 - // Position player. for { - g.player.x = float64(rand.Intn(108)) - g.player.y = float64(rand.Intn(108)) + g.player.x = float64(rand.Intn(g.level.w)) + g.player.y = float64(rand.Intn(g.level.h)) if g.level.isFloor(g.player.x, g.player.y, false) { break } } - // Remove projectiles. - g.projectiles = nil - // Spawn items. g.level.items = nil added := make(map[string]bool) @@ -447,6 +323,30 @@ func (g *game) reset() error { return nil } +func (g *game) reset() error { + log.Println("Starting a new game") + + g.tick = 0 + + g.levelNum = 1 + + err := g.generateLevel() + if err != nil { + return err + } + + // Reset player score. + g.player.score = 0 + + // Reset souls rescued. + g.player.soulsRescued = 0 + + // Reset player health. + g.player.health = 3 + + return nil +} + // Layout is called when the game's layout changes. func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) { s := ebiten.DeviceScaleFactor() @@ -593,6 +493,24 @@ func (g *game) Update() error { g.camScaleTo = 4 } TODO */ + // Update target zoom level. + if g.debugMode { + var scrollY float64 + if ebiten.IsKeyPressed(ebiten.KeyC) || ebiten.IsKeyPressed(ebiten.KeyPageDown) { + scrollY = -0.25 + } else if ebiten.IsKeyPressed(ebiten.KeyE) || ebiten.IsKeyPressed(ebiten.KeyPageUp) { + scrollY = .25 + } else { + _, scrollY = ebiten.Wheel() + if scrollY < -1 { + scrollY = -1 + } else if scrollY > 1 { + scrollY = 1 + } + } + g.camScaleTo += scrollY * (g.camScaleTo / 7) + } + // Smooth zoom transition. div := 10.0 if g.camScaleTo > g.camScale { @@ -834,6 +752,21 @@ UPDATEPROJECTILES: g.player.holyWaters++ g.flashMessage("+ HOLY WATER") } + if ebiten.IsKeyPressed(ebiten.KeyControl) && inpututil.IsKeyJustPressed(ebiten.Key6) { + g.fullBrightMode = !g.fullBrightMode + if g.fullBrightMode { + g.flashMessage("FULLBRIGHT MODE ACTIVATED") + } else { + g.flashMessage("FULLBRIGHT MODE DEACTIVATED") + } + } + if inpututil.IsKeyJustPressed(ebiten.KeyEqual) { + err := g.nextLevel() + if err != nil { + return err + } + g.flashMessage(fmt.Sprintf("WARPED TO LEVEL %d", g.levelNum)) + } if inpututil.IsKeyJustPressed(ebiten.KeyV) { g.debugMode = !g.debugMode if g.debugMode { @@ -922,7 +855,7 @@ func (g *game) Draw(screen *ebiten.Image) { for i := 0; i < g.player.health; i++ { g.op.GeoM.Reset() g.op.GeoM.Translate(float64(heartX+(i*heartSpace)), 32) - screen.DrawImage(g.heartImg, g.op) + screen.DrawImage(imageAtlas[ImageHeart], g.op) } holyWaterSpace := 16 @@ -930,7 +863,7 @@ func (g *game) Draw(screen *ebiten.Image) { for i := 0; i < g.player.holyWaters; i++ { g.op.GeoM.Reset() g.op.GeoM.Translate(float64(holyWaterX+(i*holyWaterSpace)), 76) - screen.DrawImage(g.holyWaterImage, g.op) + screen.DrawImage(imageAtlas[ImageHolyWater], g.op) } scoreLabel := numberPrinter.Sprintf("%d", g.player.score) @@ -1002,6 +935,10 @@ func (g *game) renderSprite(x float64, y float64, offsetx float64, offsety float // Calculate color scale to apply shadows. func (g *game) colorScale(x, y float64) float64 { + if g.fullBrightMode { + return 1 + } + dx, dy := deltaXY(x, y, g.player.x, g.player.y) sD := 7 / (dx + dy) @@ -1067,7 +1004,7 @@ func (g *game) renderLevel(screen *ebiten.Image) int { } for _, p := range g.projectiles { - drawn += g.renderSprite(p.x, p.y, 0, 0, p.angle, 1.0, g.colorScale(p.x, p.y), 1.0, bulletImage, screen) + drawn += g.renderSprite(p.x, p.y, 0, 0, p.angle, 1.0, g.colorScale(p.x, p.y), 1.0, imageAtlas[ImageBullet], screen) } repelTime := g.player.garlicUntil.Sub(time.Now()) @@ -1078,7 +1015,7 @@ func (g *game) renderLevel(screen *ebiten.Image) int { if repelTime.Seconds() < 3 { alpha = repelTime.Seconds() / 12 } - drawn += g.renderSprite(g.player.x+0.25, g.player.y+0.25, -offset, -offset, 0, scale, 1.0, alpha, g.garlicImage, screen) + drawn += g.renderSprite(g.player.x+0.25, g.player.y+0.25, -offset, -offset, 0, scale, 1.0, alpha, imageAtlas[ImageGarlic], screen) } holyWaterTime := g.player.holyWaterUntil.Sub(time.Now()) @@ -1089,7 +1026,7 @@ func (g *game) renderLevel(screen *ebiten.Image) int { if holyWaterTime.Seconds() < 3 { alpha = holyWaterTime.Seconds() / 2 } - drawn += g.renderSprite(g.player.x+0.25, g.player.y+0.25, -offset, -offset, 0, scale, 1.0, alpha, g.holyWaterImage, screen) + drawn += g.renderSprite(g.player.x+0.25, g.player.y+0.25, -offset, -offset, 0, scale, 1.0, alpha, imageAtlas[ImageHolyWater], screen) } playerSprite := g.ojasSS.Frame1 @@ -1109,7 +1046,7 @@ func (g *game) renderLevel(screen *ebiten.Image) int { flashDuration := 40 * time.Millisecond if time.Since(g.player.weapon.lastFire) < flashDuration { - drawn += g.renderSprite(g.player.x, g.player.y, 39, -1, g.player.angle, 1.0, 1.0, 1.0, flashImage, screen) + drawn += g.renderSprite(g.player.x, g.player.y, 39, -1, g.player.angle, 1.0, 1.0, 1.0, imageAtlas[ImageMuzzleFlash], screen) } return drawn @@ -1125,10 +1062,10 @@ func (g *game) resetExpiredTimers() { } func (g *game) playSound(sound int, volume float64) error { - player := g.soundBuffer[sound][g.nextSound[sound]] - g.nextSound[sound]++ - if g.nextSound[sound] > 3 { - g.nextSound[sound] = 0 + player := soundAtlas[sound][nextSound[sound]] + nextSound[sound]++ + if nextSound[sound] > 3 { + nextSound[sound] = 0 } player.Pause() player.Rewind() diff --git a/img.go b/img.go new file mode 100644 index 0000000..e07aa9f --- /dev/null +++ b/img.go @@ -0,0 +1,58 @@ +package main + +import ( + "image" + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +const ( + ImageVampire1 = iota + ImageVampire2 + ImageVampire3 + ImageGarlic + ImageHolyWater + ImageHeart + ImageUzi + ImageBullet + ImageMuzzleFlash +) + +var imageMap = map[int]string{ + ImageVampire1: "assets/creeps/vampire/vampire1.png", + ImageVampire2: "assets/creeps/vampire/vampire2.png", + ImageVampire3: "assets/creeps/vampire/vampire3.png", + ImageGarlic: "assets/items/garlic.png", + ImageHolyWater: "assets/items/holywater.png", + ImageHeart: "assets/ui/heart.png", + ImageUzi: "assets/weapons/uzi.png", + ImageBullet: "assets/weapons/bullet.png", + ImageMuzzleFlash: "assets/weapons/flash.png", +} + +var imageAtlas = loadAtlas() + +func loadImage(p string) (*ebiten.Image, error) { + f, err := assetsFS.Open(p) + if err != nil { + return nil, err + } + img, _, err := image.Decode(f) + if err != nil { + return nil, err + } + return ebiten.NewImageFromImage(img), nil +} + +func loadAtlas() []*ebiten.Image { + atlas := make([]*ebiten.Image, len(imageMap)) + var err error + for imgID, imgPath := range imageMap { + atlas[imgID], err = loadImage(imgPath) + if err != nil { + log.Fatal(err) + } + } + return atlas +} diff --git a/level.go b/level.go index 898f941..86c3662 100644 --- a/level.go +++ b/level.go @@ -97,11 +97,14 @@ SPAWNLOCATION: } // NewLevel returns a new randomly generated Level. -func NewLevel() (*Level, error) { - // Create a 216x216 Level. +func NewLevel(levelNum int) (*Level, error) { + multiplier := levelNum + if multiplier > 2 { + multiplier = 2 + } l := &Level{ - w: 216, - h: 216, + w: 336 * multiplier, + h: 336 * multiplier, tileSize: 32, } @@ -110,14 +113,18 @@ func NewLevel() (*Level, error) { return nil, fmt.Errorf("failed to load embedded spritesheet: %s", err) } - dungeon := dungeon.NewDungeon(l.w/dungeonScale, 13) + rooms := 33 + if multiplier == 2 { + rooms = 66 + } + d := dungeon.NewDungeon(l.w/dungeonScale, rooms) dungeonFloor := 1 l.tiles = make([][]*Tile, l.h) for y := 0; y < l.h; y++ { l.tiles[y] = make([]*Tile, l.w) for x := 0; x < l.w; x++ { t := &Tile{} - if y < l.h-1 && dungeon.Grid[x/dungeonScale][y/dungeonScale] == dungeonFloor { + if y < l.h-1 && d.Grid[x/dungeonScale][y/dungeonScale] == dungeonFloor { if rand.Intn(13) == 0 { t.AddSprite(sandstoneSS.FloorC) } else { diff --git a/player.go b/player.go index 803dbde..39a3c09 100644 --- a/player.go +++ b/player.go @@ -1,10 +1,7 @@ package main import ( - "image" "time" - - "github.com/hajimehoshi/ebiten/v2" ) type gamePlayer struct { @@ -16,6 +13,8 @@ type gamePlayer struct { score int + soulsRescued int + health int holyWaters int @@ -25,20 +24,9 @@ type gamePlayer struct { } func NewPlayer() (*gamePlayer, error) { - f, err := assetsFS.Open("assets/weapons/uzi.png") - if err != nil { - return nil, err - } - img, _, err := image.Decode(f) - if err != nil { - return nil, err - } - - uziSprite := ebiten.NewImageFromImage(img) - p := &gamePlayer{ weapon: &playerWeapon{ - sprite: uziSprite, + sprite: imageAtlas[ImageUzi], cooldown: 100 * time.Millisecond, }, health: 3,