You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1150 lines
27 KiB
1150 lines
27 KiB
package main |
|
|
|
import ( |
|
"fmt" |
|
"image" |
|
"image/color" |
|
"log" |
|
"math" |
|
"math/rand" |
|
"os" |
|
"path" |
|
"runtime/pprof" |
|
"time" |
|
|
|
"github.com/hajimehoshi/ebiten/v2" |
|
"github.com/hajimehoshi/ebiten/v2/audio" |
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil" |
|
"github.com/hajimehoshi/ebiten/v2/inpututil" |
|
"golang.org/x/image/colornames" |
|
"golang.org/x/text/language" |
|
"golang.org/x/text/message" |
|
) |
|
|
|
var numberPrinter = message.NewPrinter(language.English) |
|
|
|
var colorBlood = color.RGBA{102, 0, 0, 255} |
|
|
|
const ( |
|
gunshotVolume = 0.2 |
|
vampireDieVolume = 0.15 |
|
batDieVolume = 1.5 |
|
playerHurtVolume = 0.4 |
|
playerDieVolume = 1.6 |
|
munchVolume = 0.8 |
|
|
|
spawnVampire = 777 |
|
spawnGarlic = 6 |
|
|
|
garlicActiveTime = 7 * time.Second |
|
holyWaterActiveTime = time.Second |
|
|
|
maxCreeps = 3333 // TODO optimize and raise |
|
|
|
batSoundDelay = 250 * time.Millisecond |
|
) |
|
|
|
var startButtons = []ebiten.StandardGamepadButton{ |
|
ebiten.StandardGamepadButtonRightBottom, |
|
ebiten.StandardGamepadButtonRightRight, |
|
ebiten.StandardGamepadButtonRightLeft, |
|
ebiten.StandardGamepadButtonRightTop, |
|
ebiten.StandardGamepadButtonFrontTopLeft, |
|
ebiten.StandardGamepadButtonFrontTopRight, |
|
ebiten.StandardGamepadButtonFrontBottomLeft, |
|
ebiten.StandardGamepadButtonFrontBottomRight, |
|
ebiten.StandardGamepadButtonCenterLeft, |
|
ebiten.StandardGamepadButtonCenterRight, |
|
ebiten.StandardGamepadButtonLeftStick, |
|
ebiten.StandardGamepadButtonRightStick, |
|
ebiten.StandardGamepadButtonLeftBottom, |
|
ebiten.StandardGamepadButtonLeftRight, |
|
ebiten.StandardGamepadButtonLeftLeft, |
|
ebiten.StandardGamepadButtonLeftTop, |
|
ebiten.StandardGamepadButtonCenterCenter, |
|
} |
|
|
|
type projectile struct { |
|
x, y float64 |
|
angle float64 |
|
speed float64 |
|
color color.Color |
|
} |
|
|
|
// game is an isometric demo game. |
|
type game struct { |
|
w, h int |
|
level *Level |
|
|
|
levelNum int |
|
|
|
player *gamePlayer |
|
|
|
requiredSouls int |
|
spawnedPortal bool |
|
|
|
gameStartTime time.Time |
|
|
|
gameOverTime time.Time |
|
|
|
camScale float64 |
|
camScaleTo float64 |
|
|
|
mousePanX, mousePanY int |
|
|
|
projectiles []*projectile |
|
|
|
overlayImg *ebiten.Image |
|
op *ebiten.DrawImageOptions |
|
|
|
audioContext *audio.Context |
|
|
|
lastBatSound time.Time |
|
|
|
gamepadIDs []ebiten.GamepadID |
|
gamepadIDsBuf []ebiten.GamepadID |
|
activeGamepad ebiten.GamepadID |
|
|
|
initialButtonReleased bool |
|
|
|
tick int |
|
|
|
flashMessageText string |
|
flashMessageUntil time.Time |
|
|
|
godMode bool |
|
noclipMode bool |
|
debugMode bool |
|
fullBrightMode bool |
|
cpuProfile *os.File |
|
} |
|
|
|
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, |
|
activeGamepad: -1, |
|
|
|
op: &ebiten.DrawImageOptions{}, |
|
} |
|
|
|
g.audioContext = audio.NewContext(sampleRate) |
|
|
|
ebiten.SetCursorShape(ebiten.CursorShapeCrosshair) |
|
|
|
err := g.loadAssets() |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
g.player, err = NewPlayer() |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
err = g.reset() |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
return g, nil |
|
} |
|
|
|
func (g *game) flashMessage(message string) { |
|
log.Println(message) |
|
|
|
g.flashMessageText = message |
|
g.flashMessageUntil = time.Now().Add(3 * time.Second) |
|
} |
|
|
|
func (g *game) loadAssets() error { |
|
var err error |
|
// Load SpriteSheets. |
|
ojasSS, err = LoadPlayerSpriteSheet() |
|
if err != nil { |
|
return fmt.Errorf("failed to load embedded spritesheet: %s", err) |
|
} |
|
|
|
batSS, err = LoadBatSpriteSheet() |
|
if err != nil { |
|
return fmt.Errorf("failed to load embedded spritesheet: %s", err) |
|
} |
|
|
|
soundAtlas = loadSoundAtlas(g.audioContext) |
|
|
|
return nil |
|
} |
|
|
|
func (g *game) newItem(itemType int) *gameItem { |
|
sprite := imageAtlas[ImageGarlic] |
|
if itemType == itemTypeHolyWater { |
|
sprite = imageAtlas[ImageHolyWater] |
|
} |
|
x, y := g.level.newSpawnLocation() |
|
return &gameItem{ |
|
itemType: itemType, |
|
x: x, |
|
y: y, |
|
sprite: sprite, |
|
level: g.level, |
|
player: g.player, |
|
health: 1, |
|
} |
|
} |
|
|
|
func (g *game) nextLevel() error { |
|
g.levelNum++ |
|
if g.levelNum > 13 { |
|
log.Fatal("YOU WIN") |
|
} |
|
return g.generateLevel() |
|
} |
|
|
|
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.levelNum, g.player) |
|
if err != nil { |
|
return fmt.Errorf("failed to create new level: %s", err) |
|
} |
|
|
|
// Position player. |
|
for { |
|
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 |
|
} |
|
} |
|
|
|
// Spawn items. |
|
g.level.items = nil |
|
for i := 0; i < spawnGarlic; i++ { |
|
itemType := itemTypeGarlic |
|
c := g.newItem(itemType) |
|
g.level.items = append(g.level.items, c) |
|
} |
|
// Spawn starting garlic. |
|
item := g.newItem(itemTypeGarlic) |
|
for { |
|
garlicOffsetA := 8 - float64(rand.Intn(16)) |
|
garlicOffsetB := 8 - float64(rand.Intn(16)) |
|
startingGarlicX := g.player.x + 2 + garlicOffsetA |
|
startingGarlicY := g.player.y + 2 + garlicOffsetB |
|
|
|
if g.level.isFloor(startingGarlicX, startingGarlicY, false) { |
|
item.x = startingGarlicX |
|
item.y = startingGarlicY |
|
break |
|
} |
|
} |
|
g.level.items = append(g.level.items, item) |
|
|
|
// Spawn creeps. |
|
for i := 0; i < spawnVampire; i++ { |
|
g.level.addCreep(TypeVampire) |
|
} |
|
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() |
|
w, h := int(s*float64(outsideWidth)), int(s*float64(outsideHeight)) |
|
if w != g.w || h != g.h { |
|
g.w, g.h = w, h |
|
|
|
debugBox := image.NewRGBA(image.Rect(0, 0, g.w, 200)) |
|
g.overlayImg = ebiten.NewImageFromImage(debugBox) |
|
} |
|
if g.player.weapon.spriteFlipped == nil { |
|
op := &ebiten.DrawImageOptions{} |
|
op.GeoM.Scale(-1, 1) |
|
op.GeoM.Translate(32, 0) |
|
spriteFlipped := ebiten.NewImageFromImage(g.player.weapon.sprite) |
|
spriteFlipped.Clear() |
|
spriteFlipped.DrawImage(g.player.weapon.sprite, op) |
|
g.player.weapon.spriteFlipped = spriteFlipped |
|
} |
|
return g.w, g.h |
|
} |
|
|
|
// Update reads current user input and updates the game state. |
|
func (g *game) Update() error { |
|
gamepadDeadZone := 0.1 |
|
|
|
if ebiten.IsKeyPressed(ebiten.KeyEscape) || ebiten.IsWindowBeingClosed() { |
|
g.exit() |
|
return nil |
|
} |
|
|
|
if g.player.health <= 0 && !g.godMode { |
|
// Game over. |
|
if ebiten.IsKeyPressed(ebiten.KeyEnter) || (g.activeGamepad != -1 && ebiten.IsStandardGamepadButtonPressed(g.activeGamepad, ebiten.StandardGamepadButtonCenterRight)) { |
|
err := g.reset() |
|
if err != nil { |
|
return err |
|
} |
|
|
|
g.gameOverTime = time.Time{} |
|
} |
|
return nil |
|
} |
|
|
|
g.gamepadIDsBuf = inpututil.AppendJustConnectedGamepadIDs(g.gamepadIDsBuf[:0]) |
|
for _, id := range g.gamepadIDsBuf { |
|
log.Printf("gamepad connected: %d", id) |
|
g.gamepadIDs = append(g.gamepadIDs, id) |
|
} |
|
for i, id := range g.gamepadIDs { |
|
if inpututil.IsGamepadJustDisconnected(id) { |
|
log.Printf("gamepad disconnected: %d", id) |
|
g.gamepadIDs = append(g.gamepadIDs[:i], g.gamepadIDs[i+1:]...) |
|
} |
|
|
|
if g.activeGamepad == -1 { |
|
for _, button := range startButtons { |
|
if ebiten.IsStandardGamepadButtonPressed(id, button) { |
|
log.Printf("gamepad activated: %d", id) |
|
g.activeGamepad = id |
|
ebiten.SetCursorMode(ebiten.CursorModeHidden) |
|
break |
|
} |
|
} |
|
} |
|
} |
|
|
|
if g.gameStartTime.IsZero() { |
|
var pressedKeys []ebiten.Key |
|
pressedKeys = inpututil.AppendPressedKeys(pressedKeys) |
|
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) || g.activeGamepad != -1 || len(pressedKeys) > 0 { |
|
g.gameStartTime = time.Now() |
|
} |
|
return nil |
|
} |
|
|
|
g.resetExpiredTimers() |
|
|
|
biteThreshold := 0.75 |
|
liveCreeps := 0 |
|
for _, c := range g.level.creeps { |
|
if c.health == 0 { |
|
continue |
|
} |
|
|
|
c.Update() |
|
|
|
if c.creepType == TypeTorch { |
|
continue |
|
} |
|
|
|
// TODO can this move into creep? |
|
cx, cy := c.Position() |
|
dx, dy := deltaXY(g.player.x, g.player.y, cx, cy) |
|
if dx <= biteThreshold && dy <= biteThreshold { |
|
if !g.godMode && !c.repelled() { |
|
if g.player.holyWaters > 0 { |
|
// TODO g.playSound(SoundItemUseHolyWater, useholyWaterVolume) |
|
g.player.holyWaterUntil = time.Now().Add(holyWaterActiveTime) |
|
g.player.holyWaters-- |
|
} else { |
|
err := g.hurtCreep(c, -1) |
|
if err != nil { |
|
// TODO |
|
panic(err) |
|
} |
|
|
|
g.player.health-- |
|
|
|
if g.player.health == 2 { |
|
g.playSound(SoundPlayerHurt, playerHurtVolume/2) |
|
} else if g.player.health == 1 { |
|
g.playSound(SoundPlayerHurt, playerHurtVolume) |
|
} |
|
|
|
g.addBloodSplatter(g.player.x, g.player.y) |
|
} |
|
} |
|
|
|
if g.player.health == 0 { |
|
ebiten.SetCursorShape(ebiten.CursorShapeDefault) |
|
|
|
g.player.holyWaters = 0 |
|
|
|
g.gameOverTime = time.Now() |
|
|
|
// Play die sound. |
|
err := g.playSound(SoundPlayerDie, playerDieVolume) |
|
if err != nil { |
|
// TODO return err |
|
panic(err) |
|
} |
|
} |
|
} else if c.creepType == TypeBat && (dx <= 12 && dy <= 7) && rand.Intn(166) == 6 && time.Since(g.lastBatSound) >= batSoundDelay { |
|
g.playSound(SoundBat, batDieVolume) |
|
g.lastBatSound = time.Now() |
|
} |
|
|
|
if c.health > 0 { |
|
liveCreeps++ |
|
} |
|
} |
|
g.level.liveCreeps = liveCreeps |
|
|
|
// Clamp target zoom level. |
|
/*if g.camScaleTo < 2 { |
|
g.camScaleTo = 2 |
|
} else if g.camScaleTo > 4 { |
|
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 { |
|
g.camScale += (g.camScaleTo - g.camScale) / div |
|
} else if g.camScaleTo < g.camScale { |
|
g.camScale -= (g.camScale - g.camScaleTo) / div |
|
} |
|
|
|
pan := 0.05 |
|
|
|
// Pan camera. |
|
px, py := g.player.x, g.player.y |
|
if g.activeGamepad != -1 { |
|
h := ebiten.StandardGamepadAxisValue(g.activeGamepad, ebiten.StandardGamepadAxisLeftStickHorizontal) |
|
v := ebiten.StandardGamepadAxisValue(g.activeGamepad, ebiten.StandardGamepadAxisLeftStickVertical) |
|
if v < -gamepadDeadZone || v > gamepadDeadZone || h < -gamepadDeadZone || h > gamepadDeadZone { |
|
px += h * pan |
|
py += v * pan |
|
} |
|
} else { |
|
if ebiten.IsKeyPressed(ebiten.KeyShift) { |
|
pan /= 2 |
|
} |
|
|
|
if ebiten.IsKeyPressed(ebiten.KeyLeft) || ebiten.IsKeyPressed(ebiten.KeyA) { |
|
px -= pan |
|
} |
|
if ebiten.IsKeyPressed(ebiten.KeyRight) || ebiten.IsKeyPressed(ebiten.KeyD) { |
|
px += pan |
|
} |
|
if ebiten.IsKeyPressed(ebiten.KeyDown) || ebiten.IsKeyPressed(ebiten.KeyS) { |
|
py += pan |
|
} |
|
if ebiten.IsKeyPressed(ebiten.KeyUp) || ebiten.IsKeyPressed(ebiten.KeyW) { |
|
py -= pan |
|
} |
|
} |
|
|
|
if g.noclipMode || g.level.isFloor(px, py, false) { |
|
g.player.x, g.player.y = px, py |
|
} else if g.level.isFloor(px, g.player.y, false) { |
|
g.player.x = px |
|
} else if g.level.isFloor(g.player.x, py, false) { |
|
g.player.y = py |
|
} |
|
|
|
for _, item := range g.level.items { |
|
if item.health == 0 { |
|
continue |
|
} |
|
|
|
dx, dy := deltaXY(g.player.x, g.player.y, item.x, item.y) |
|
if dx <= 1 && dy <= 1 { |
|
item.health = 0 |
|
g.player.score += item.useScore() * g.levelNum |
|
|
|
if item.itemType == itemTypeGarlic { |
|
g.playSound(SoundMunch, munchVolume) |
|
g.player.garlicUntil = time.Now().Add(garlicActiveTime) |
|
} else if item.itemType == itemTypeHolyWater { |
|
// TODO g.playSound(SoundItemPickup, munchVolume) |
|
g.player.holyWaters++ |
|
} |
|
} |
|
} |
|
|
|
fire := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) |
|
|
|
// Update player angle. |
|
if g.activeGamepad != -1 { |
|
h := ebiten.StandardGamepadAxisValue(g.activeGamepad, ebiten.StandardGamepadAxisRightStickHorizontal) |
|
v := ebiten.StandardGamepadAxisValue(g.activeGamepad, ebiten.StandardGamepadAxisRightStickVertical) |
|
if v < -gamepadDeadZone || v > gamepadDeadZone || h < -gamepadDeadZone || h > gamepadDeadZone { |
|
g.player.angle = angle(h, v, 0, 0) |
|
fire = true |
|
} |
|
} else { |
|
cx, cy := ebiten.CursorPosition() |
|
g.player.angle = angle(float64(cx), float64(cy), float64(g.w/2), float64(g.h/2)) |
|
} |
|
|
|
if !g.initialButtonReleased { |
|
if fire { |
|
fire = false |
|
} else { |
|
g.initialButtonReleased = true |
|
} |
|
} |
|
|
|
// Update boolets. |
|
bulletHitThreshold := 0.5 |
|
removed := 0 |
|
UPDATEPROJECTILES: |
|
for i, p := range g.projectiles { |
|
p.x += math.Cos(p.angle) * p.speed |
|
p.y += math.Sin(p.angle) * p.speed |
|
|
|
for _, c := range g.level.creeps { |
|
if c.health == 0 { |
|
continue |
|
} |
|
|
|
threshold := bulletHitThreshold |
|
if c.creepType == TypeTorch { |
|
threshold = 1 |
|
} |
|
|
|
cx, cy := c.Position() |
|
dx, dy := deltaXY(p.x, p.y, cx, cy) |
|
if dx > threshold || dy > threshold { |
|
continue |
|
} |
|
|
|
err := g.hurtCreep(c, 1) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
// Remove projectile |
|
g.projectiles = append(g.projectiles[:i-removed], g.projectiles[i-removed+1:]...) |
|
removed++ |
|
|
|
continue UPDATEPROJECTILES |
|
} |
|
|
|
if !g.level.isFloor(p.x, p.y, true) { |
|
// Remove projectile |
|
g.projectiles = append(g.projectiles[:i-removed], g.projectiles[i-removed+1:]...) |
|
removed++ |
|
// TODO Add bullet hole |
|
} |
|
} |
|
|
|
// Fire boolets. |
|
if fire && time.Since(g.player.weapon.lastFire) >= g.player.weapon.cooldown { |
|
p := &projectile{ |
|
x: g.player.x, |
|
y: g.player.y, |
|
angle: g.player.angle, |
|
speed: 0.35, |
|
color: colornames.Yellow, |
|
} |
|
g.projectiles = append(g.projectiles, p) |
|
|
|
g.player.weapon.lastFire = time.Now() |
|
|
|
// Play gunshot sound. |
|
err := g.playSound(SoundGunshot, gunshotVolume) |
|
if err != nil { |
|
return err |
|
} |
|
} |
|
|
|
// Remove dead creeps. |
|
if g.tick%200 == 0 { |
|
removed = 0 |
|
for i, creep := range g.level.creeps { |
|
if creep.health != 0 || creep.creepType == TypeTorch { |
|
continue |
|
} |
|
|
|
// Remove creep. |
|
g.level.creeps = append(g.level.creeps[:i-removed], g.level.creeps[i-removed+1:]...) |
|
removed++ |
|
} |
|
} |
|
|
|
// Spawn garlic. |
|
if g.tick%(144*45) == 0 { |
|
item := g.newItem(itemTypeGarlic) |
|
g.level.items = append(g.level.items, item) |
|
|
|
if g.debugMode { |
|
g.flashMessage("SPAWN GARLIC") |
|
} |
|
} |
|
|
|
// Spawn holy water. |
|
if g.tick%(144*120) == 0 || rand.Intn(660) == 0 { // TODO |
|
item := g.newItem(itemTypeHolyWater) |
|
g.level.items = append(g.level.items, item) |
|
|
|
if g.debugMode { |
|
g.flashMessage("SPAWN HOLY WATER") |
|
} |
|
} |
|
|
|
if len(g.level.creeps) < maxCreeps { |
|
// Spawn vampires. |
|
if g.tick%144 == 0 { |
|
spawnAmount := rand.Intn(26 + (g.tick / (144 * 3))) |
|
if len(g.level.creeps) < 500 { |
|
spawnAmount *= 4 |
|
} |
|
if g.debugMode && spawnAmount > 0 { |
|
g.flashMessage(fmt.Sprintf("SPAWN %d VAMPIRES", spawnAmount)) |
|
} |
|
for i := 0; i < spawnAmount; i++ { |
|
g.level.addCreep(TypeVampire) |
|
} |
|
} |
|
|
|
// Spawn bats. |
|
if g.tick%144 == 0 { |
|
spawnAmount := g.tick / 288 |
|
if spawnAmount < 1 { |
|
spawnAmount = 1 |
|
} else if spawnAmount > 12 { |
|
spawnAmount = 12 |
|
} |
|
spawnAmount = rand.Intn(spawnAmount) |
|
if g.debugMode && spawnAmount > 0 { |
|
g.flashMessage(fmt.Sprintf("SPAWN %d BATS", spawnAmount)) |
|
} |
|
for i := 0; i < spawnAmount; i++ { |
|
g.level.addCreep(TypeBat) |
|
} |
|
} |
|
|
|
// Spawn ghosts. |
|
if g.tick%1872 == 0 { |
|
spawnAmount := g.tick / 1872 |
|
if spawnAmount < 1 { |
|
spawnAmount = 1 |
|
} else if spawnAmount > 6 { |
|
spawnAmount = 6 |
|
} |
|
spawnAmount = rand.Intn(spawnAmount) |
|
if g.debugMode && spawnAmount > 0 { |
|
g.flashMessage(fmt.Sprintf("SPAWN %d GHOSTS", spawnAmount)) |
|
} |
|
for i := 0; i < spawnAmount; i++ { |
|
g.level.addCreep(TypeGhost) |
|
} |
|
} |
|
} |
|
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyG) { |
|
g.godMode = !g.godMode |
|
if g.godMode { |
|
g.flashMessage("GOD MODE ACTIVATED") |
|
} else { |
|
g.flashMessage("GOD MODE DEACTIVATED") |
|
} |
|
} |
|
if inpututil.IsKeyJustPressed(ebiten.KeyN) { |
|
g.noclipMode = !g.noclipMode |
|
if g.noclipMode { |
|
g.flashMessage("NOCLIP MODE ACTIVATED") |
|
} else { |
|
g.flashMessage("NOCLIP MODE DEACTIVATED") |
|
} |
|
} |
|
if inpututil.IsKeyJustPressed(ebiten.KeyH) { |
|
g.player.holyWaters++ |
|
g.flashMessage("+ HOLY WATER") |
|
} |
|
if ebiten.IsKeyPressed(ebiten.KeyControl) && inpututil.IsKeyJustPressed(ebiten.Key2) { |
|
for i := 0; i < 13; i++ { |
|
g.level.addCreep(TypeGhost) |
|
} |
|
g.flashMessage("+ GHOST") |
|
} |
|
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 { |
|
g.flashMessage("DEBUG MODE ACTIVATED") |
|
} else { |
|
g.flashMessage("DEBUG MODE DEACTIVATED") |
|
} |
|
} |
|
if inpututil.IsKeyJustPressed(ebiten.KeyP) { |
|
if g.cpuProfile == nil { |
|
g.flashMessage("CPU PROFILING STARTED") |
|
|
|
homeDir, err := os.UserHomeDir() |
|
if err != nil { |
|
return err |
|
} |
|
g.cpuProfile, err = os.Create(path.Join(homeDir, "cartillery.prof")) |
|
if err != nil { |
|
return err |
|
} |
|
if err := pprof.StartCPUProfile(g.cpuProfile); err != nil { |
|
return err |
|
} |
|
} else { |
|
g.flashMessage("CPU PROFILING STOPPED") |
|
|
|
pprof.StopCPUProfile() |
|
g.cpuProfile.Close() |
|
g.cpuProfile = nil |
|
} |
|
} |
|
|
|
g.tick++ |
|
return nil |
|
} |
|
|
|
func (g *game) drawText(target *ebiten.Image, y float64, scale float64, alpha float64, text string) { |
|
g.overlayImg.Clear() |
|
ebitenutil.DebugPrint(g.overlayImg, text) |
|
g.op.GeoM.Reset() |
|
g.op.GeoM.Scale(scale, scale) |
|
g.op.GeoM.Translate(float64(g.w/2)-(float64(len(text))*3*scale), y) |
|
g.op.ColorM.Scale(1, 1, 1, alpha) |
|
target.DrawImage(g.overlayImg, g.op) |
|
g.op.ColorM.Reset() |
|
} |
|
|
|
// Draw draws the game on the screen. |
|
func (g *game) Draw(screen *ebiten.Image) { |
|
if g.gameStartTime.IsZero() { |
|
screen.Fill(colorBlood) |
|
|
|
g.drawText(screen, float64(g.h/2)-350, 16, 1.0, "CAROTID") |
|
g.drawText(screen, float64(g.h/2)-100, 16, 1.0, "ARTILLERY") |
|
|
|
g.drawText(screen, float64(g.h-210), 4, 1.0, "WASD + MOUSE = OK") |
|
g.drawText(screen, float64(g.h-145), 4, 1.0, "FULLSCREEN + GAMEPAD = BEST") |
|
|
|
if time.Now().UnixMilli()%2000 < 1500 { |
|
g.drawText(screen, float64(g.h-80), 4, 1.0, "PRESS ANY KEY OR BUTTON TO START") |
|
} |
|
|
|
return |
|
} |
|
|
|
gameOver := g.player.health <= 0 && !g.godMode |
|
|
|
var drawn int |
|
if !gameOver { |
|
drawn = g.renderLevel(screen) |
|
} else { |
|
// Game over. |
|
screen.Fill(colorBlood) |
|
|
|
g.drawText(screen, float64(g.h/2)-150, 16, 1.0, "GAME OVER") |
|
|
|
if time.Since(g.gameOverTime).Milliseconds()%2000 < 1500 { |
|
g.drawText(screen, 8, 4, 1.0, "PRESS ENTER OR START TO PLAY AGAIN") |
|
} |
|
} |
|
|
|
heartSpace := 32 |
|
heartX := (g.w / 2) - ((heartSpace * g.player.health) / 2) + 8 |
|
for i := 0; i < g.player.health; i++ { |
|
g.op.GeoM.Reset() |
|
g.op.GeoM.Translate(float64(heartX+(i*heartSpace)), 32) |
|
screen.DrawImage(imageAtlas[ImageHeart], g.op) |
|
} |
|
|
|
holyWaterSpace := 16 |
|
holyWaterX := (g.w / 2) - ((holyWaterSpace * g.player.holyWaters) / 2) |
|
for i := 0; i < g.player.holyWaters; i++ { |
|
g.op.GeoM.Reset() |
|
g.op.GeoM.Translate(float64(holyWaterX+(i*holyWaterSpace)), 76) |
|
screen.DrawImage(imageAtlas[ImageHolyWater], g.op) |
|
} |
|
|
|
scoreLabel := numberPrinter.Sprintf("%d", g.player.score) |
|
g.drawText(screen, float64(g.h-150), 8, 1.0, scoreLabel) |
|
|
|
flashTime := g.flashMessageUntil.Sub(time.Now()) |
|
if flashTime > 0 { |
|
alpha := flashTime.Seconds() * 4 |
|
if alpha > 1 { |
|
alpha = 1 |
|
} |
|
g.drawText(screen, float64(g.h-40), 2, alpha, g.flashMessageText) |
|
} |
|
|
|
if !g.debugMode { |
|
return |
|
} |
|
|
|
// Print game info. |
|
g.overlayImg.Clear() |
|
ebitenutil.DebugPrint(g.overlayImg, fmt.Sprintf("CRP %d\nSPR %d\nTPS %0.0f\nFPS %0.0f", g.level.liveCreeps, drawn, ebiten.CurrentTPS(), ebiten.CurrentFPS())) |
|
g.op.GeoM.Reset() |
|
g.op.GeoM.Translate(3, 0) |
|
g.op.GeoM.Scale(2, 2) |
|
screen.DrawImage(g.overlayImg, g.op) |
|
} |
|
|
|
// tilePosition transforms X,Y coordinates into tile positions. |
|
func (g *game) tilePosition(x, y float64) (float64, float64) { |
|
tileSize := float64(g.level.tileSize) |
|
return x * tileSize, y * tileSize |
|
} |
|
|
|
// renderSprite renders a sprite on the screen. |
|
func (g *game) renderSprite(x float64, y float64, offsetx float64, offsety float64, angle float64, scale float64, colorScale float64, alpha float64, sprite *ebiten.Image, target *ebiten.Image) int { |
|
if alpha <= .01 || colorScale <= .01 { |
|
return 0 |
|
} |
|
|
|
x, y = g.tilePosition(x, y) |
|
|
|
// Skip drawing off-screen tiles. |
|
drawX, drawY := g.levelCoordinatesToScreen(x, y) |
|
padding := float64(g.level.tileSize) * 2 |
|
if drawX+padding < 0 || drawY+padding < 0 || drawX > float64(g.w)+padding || drawY > float64(g.h)+padding { |
|
return 0 |
|
} |
|
|
|
g.op.GeoM.Reset() |
|
|
|
g.op.GeoM.Scale(scale, scale) |
|
// Rotate |
|
g.op.GeoM.Translate(-16+offsetx, -16+offsety) |
|
g.op.GeoM.Rotate(angle) |
|
// Move to current isometric position. |
|
g.op.GeoM.Translate(x, y) |
|
// Translate camera position. |
|
px, py := g.tilePosition(g.player.x, g.player.y) |
|
g.op.GeoM.Translate(-px, -py) |
|
// Zoom. |
|
g.op.GeoM.Scale(g.camScale, g.camScale) |
|
// Center. |
|
g.op.GeoM.Translate(float64(g.w/2.0), float64(g.h/2.0)) |
|
|
|
g.op.ColorM.Scale(colorScale, colorScale, colorScale, alpha) |
|
|
|
target.DrawImage(sprite, g.op) |
|
|
|
g.op.ColorM.Reset() |
|
|
|
return 1 |
|
} |
|
|
|
// Calculate color scale to apply shadows. |
|
func (g *game) colorScale(x, y float64) float64 { |
|
if g.fullBrightMode { |
|
return 1 |
|
} |
|
|
|
v := colorScaleValue(x, y, g.player.x, g.player.y) |
|
|
|
tileV := g.level.Tile(int(x), int(y)).colorScale |
|
|
|
s := math.Min(1, v+tileV) |
|
|
|
return s |
|
} |
|
|
|
// renderLevel draws the current Level on the screen. |
|
func (g *game) renderLevel(screen *ebiten.Image) int { |
|
var drawn int |
|
|
|
var t *Tile |
|
for y := 0; y < g.level.h; y++ { |
|
for x := 0; x < g.level.w; x++ { |
|
t = g.level.tiles[y][x] |
|
if t == nil { |
|
continue // No tile at this position. |
|
} |
|
|
|
for i := range t.sprites { |
|
drawn += g.renderSprite(float64(x), float64(y), 0, 0, 0, 1.0, g.colorScale(float64(x), float64(y)), 1.0, t.sprites[i], screen) |
|
} |
|
} |
|
} |
|
|
|
for _, item := range g.level.items { |
|
if item.health == 0 { |
|
continue |
|
} |
|
|
|
drawn += g.renderSprite(item.x, item.y, 0, 0, 0, 1.0, g.colorScale(item.x, item.y), 1.0, item.sprite, screen) |
|
} |
|
|
|
for _, c := range g.level.creeps { |
|
if c.health == 0 && c.creepType != TypeTorch { |
|
continue |
|
} |
|
|
|
drawn += g.renderSprite(c.x, c.y, 0, 0, c.angle, 1.0, g.colorScale(c.x, c.y), 1.0, c.sprites[c.frame], screen) |
|
if c.frames > 1 && time.Since(c.lastFrame) >= 75*time.Millisecond { |
|
c.frame++ |
|
if c.frame == c.frames { |
|
c.frame = 0 |
|
} |
|
c.lastFrame = time.Now() |
|
} |
|
} |
|
|
|
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, imageAtlas[ImageBullet], screen) |
|
} |
|
|
|
repelTime := g.player.garlicUntil.Sub(time.Now()) |
|
if repelTime > 0 && repelTime < 7*time.Second { |
|
scale := repelTime.Seconds() + 1 |
|
offset := 12 * scale |
|
alpha := 0.25 |
|
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, imageAtlas[ImageGarlic], screen) |
|
} |
|
|
|
holyWaterTime := g.player.holyWaterUntil.Sub(time.Now()) |
|
if holyWaterTime > 0 && holyWaterTime < time.Second { |
|
scale := (holyWaterTime.Seconds() + 1) * 2 |
|
offset := 16 * scale |
|
alpha := 0.25 |
|
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, imageAtlas[ImageHolyWater], screen) |
|
} |
|
|
|
playerSprite := ojasSS.Frame1 |
|
playerAngle := g.player.angle |
|
weaponSprite := g.player.weapon.spriteFlipped |
|
mul := float64(1) |
|
if g.player.angle > math.Pi/2 || g.player.angle < -1*math.Pi/2 { |
|
playerSprite = ojasSS.Frame2 |
|
playerAngle = playerAngle - math.Pi |
|
weaponSprite = g.player.weapon.sprite |
|
mul = -1 |
|
} |
|
drawn += g.renderSprite(g.player.x, g.player.y, 0, 0, playerAngle, 1.0, 1.0, 1.0, playerSprite, screen) |
|
drawn += g.renderSprite(g.player.x, g.player.y, -10*mul, 2, playerAngle, 1.0, 1.0, 1.0, sandstoneSS.TorchMulti, screen) |
|
if g.player.weapon != nil { |
|
drawn += g.renderSprite(g.player.x, g.player.y, 11*mul, 9, playerAngle, 1.0, 1.0, 1.0, weaponSprite, screen) |
|
} |
|
|
|
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, imageAtlas[ImageMuzzleFlash], screen) |
|
} |
|
|
|
return drawn |
|
} |
|
|
|
func (g *game) resetExpiredTimers() { |
|
if !g.player.garlicUntil.IsZero() && g.player.garlicUntil.Sub(time.Now()) <= 0 { |
|
g.player.garlicUntil = time.Time{} |
|
} |
|
if !g.player.holyWaterUntil.IsZero() && g.player.holyWaterUntil.Sub(time.Now()) <= 0 { |
|
g.player.holyWaterUntil = time.Time{} |
|
} |
|
} |
|
|
|
func (g *game) playSound(sound int, volume float64) error { |
|
player := soundAtlas[sound][nextSound[sound]] |
|
nextSound[sound]++ |
|
if nextSound[sound] > 3 { |
|
nextSound[sound] = 0 |
|
} |
|
player.Pause() |
|
player.Rewind() |
|
player.SetVolume(volume) |
|
player.Play() |
|
return nil |
|
} |
|
|
|
func (g *game) hurtCreep(c *gameCreep, damage int) error { |
|
if damage == -1 { |
|
c.health = 0 |
|
return nil |
|
} |
|
|
|
c.health -= damage |
|
if c.health > 0 { |
|
return nil |
|
} |
|
|
|
// Killed creep. |
|
g.player.score += c.killScore() * g.levelNum |
|
|
|
if c.creepType == TypeTorch { |
|
// TODO play break sound |
|
c.frames = 1 |
|
c.frame = 0 |
|
c.sprites = []*ebiten.Image{ |
|
sandstoneSS.TorchTop9, |
|
} |
|
g.level.bakePartialLightmap(int(c.x), int(c.y)) |
|
return nil |
|
} |
|
|
|
// Play vampire die sound. |
|
|
|
var volume float64 |
|
var dieSound int |
|
|
|
dieSound = SoundVampireDie1 |
|
if rand.Intn(2) == 1 { |
|
dieSound = SoundVampireDie2 |
|
} |
|
volume = vampireDieVolume |
|
/* |
|
if c.creepType == TypeBat { |
|
dieSound = SoundBat |
|
volume = batDieVolume |
|
} else { |
|
dieSound = SoundVampireDie1 |
|
if rand.Intn(2) == 1 { |
|
dieSound = SoundVampireDie2 |
|
} |
|
volume = vampireDieVolume |
|
} |
|
*/ |
|
|
|
dx, dy := deltaXY(g.player.x, g.player.y, c.x, c.y) |
|
distance := dx |
|
if dy > dx { |
|
distance = dy |
|
} |
|
if distance > 9 { |
|
volume *= 0.7 |
|
} else if distance > 6 { |
|
volume *= 0.85 |
|
} |
|
|
|
err := g.playSound(dieSound, volume) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
g.addBloodSplatter(c.x, c.y) |
|
|
|
return nil |
|
} |
|
|
|
func (g *game) levelCoordinatesToScreen(x, y float64) (float64, float64) { |
|
px, py := g.tilePosition(g.player.x, g.player.y) |
|
py *= -1 |
|
return ((x - px) * g.camScale) + float64(g.w/2.0), ((y + py) * g.camScale) + float64(g.h/2.0) |
|
} |
|
|
|
func (g *game) addBloodSplatter(x, y float64) { |
|
splatterSprite := ebiten.NewImage(32, 32) |
|
|
|
for y := 8; y < 20; y++ { |
|
if rand.Intn(2) != 0 { |
|
continue |
|
} |
|
for x := 12; x < 20; x++ { |
|
if rand.Intn(5) != 0 { |
|
continue |
|
} |
|
splatterSprite.Set(x, y, colornames.Red) |
|
} |
|
} |
|
for y := 2; y < 26; y++ { |
|
if rand.Intn(5) != 0 { |
|
continue |
|
} |
|
for x := 2; x < 26; x++ { |
|
if rand.Intn(12) != 0 { |
|
continue |
|
} |
|
splatterSprite.Set(x, y, colornames.Red) |
|
} |
|
} |
|
|
|
t := g.level.Tile(int(x), int(y)) |
|
if t != nil { |
|
t.AddSprite(splatterSprite) |
|
} |
|
} |
|
|
|
func (g *game) exit() { |
|
os.Exit(0) |
|
} |
|
|
|
func deltaXY(x1, y1, x2, y2 float64) (dx float64, dy float64) { |
|
dx, dy = x1-x2, y1-y2 |
|
if dx < 0 { |
|
dx *= -1 |
|
} |
|
if dy < 0 { |
|
dy *= -1 |
|
} |
|
return dx, dy |
|
}
|
|
|