Integrate view-map into ordoor

Now we can view scenario maps from the main game interface. We can't
cancel out of a scenario yet, though.
This commit is contained in:
2020-04-11 00:13:28 +01:00
parent 0025daf8dd
commit 5f8606377a
10 changed files with 289 additions and 233 deletions

View File

@@ -2,15 +2,13 @@ package main
import (
"flag"
"image"
"log"
"math"
"os"
"sort"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/scenario"
"code.ur.gs/lupine/ordoor/internal/ui"
)
@@ -23,18 +21,7 @@ var (
)
type env struct {
assets *assetstore.AssetStore
area *assetstore.Map
step int
state state
lastState state
}
type state struct {
zoom float64
origin image.Point
zIdx int
scenario *scenario.Scenario
}
func main() {
@@ -50,26 +37,13 @@ func main() {
log.Fatalf("Failed to scan root directory %v: %v", *gamePath, err)
}
area, err := assets.Map(*gameMap)
scenario, err := scenario.NewScenario(assets, *gameMap)
if err != nil {
log.Fatalf("Failed to load map %v: %v", *gameMap, err)
log.Fatalf("Failed to load scenario %v: %v", *gameMap, err)
}
// Eager load sprites
if err := area.LoadSprites(); err != nil {
log.Fatal("Eager-loading sprites failed: %v", err)
}
state := state{
zoom: 1.0,
origin: image.Point{0, 3000}, // FIXME: haxxx
zIdx: 1,
}
env := &env{
area: area,
assets: assets,
state: state,
lastState: state,
scenario: scenario,
}
win, err := ui.NewWindow(env, "View Map "+*gameMap, *winX, *winY)
@@ -83,10 +57,9 @@ func main() {
win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(+64, +0))
win.OnKeyUp(ebiten.KeyUp, env.changeOrigin(+0, -64))
win.OnKeyUp(ebiten.KeyDown, env.changeOrigin(+0, +64))
win.OnMouseWheel(env.changeZoom)
for i := 0; i < 6; i++ {
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i+1))
for i := 0; i <= 6; i++ {
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i))
}
if err := win.Run(); err != nil {
@@ -95,150 +68,22 @@ func main() {
}
func (e *env) Update(screenX, screenY int) error {
if e.step == 0 || e.lastState != e.state {
log.Printf("zoom=%.2f zIdx=%v camPos=%#v", e.state.zoom, e.state.zIdx, e.state.origin)
}
e.lastState = e.state
e.step += 1
return nil
return e.scenario.Update(screenX, screenY)
}
func (e *env) Draw(screen *ebiten.Image) error {
// Bounds clipping
// http://www.java-gaming.org/index.php?topic=24922.0
// https://stackoverflow.com/questions/892811/drawing-isometric-game-worlds
// https://gamedev.stackexchange.com/questions/25896/how-do-i-find-which-isometric-tiles-are-inside-the-cameras-current-view
sw, sh := screen.Size()
topLeft := pixToCell(e.state.origin)
topLeft.X -= 5 // Ensure we paint to every visible section of the screeen.
topLeft.X -= 5 // FIXME: haxxx
bottomRight := pixToCell(image.Pt(e.state.origin.X+sw, e.state.origin.Y+sh))
bottomRight.X += 5
bottomRight.Y += 5
// X+Y is constant for all tiles in a column
// X-Y is constant for all tiles in a row
// However, the drawing order is odd unless we reorder explicitly.
toDraw := []image.Point{}
for a := topLeft.X + topLeft.Y; a <= bottomRight.X+bottomRight.Y; a++ {
for b := topLeft.X - topLeft.Y; b <= bottomRight.X-bottomRight.Y; b++ {
if b&1 != a&1 {
continue
}
pt := image.Pt((a+b)/2, (a-b)/2)
if !pt.In(e.area.Rect) {
continue
}
toDraw = append(toDraw, pt)
}
}
sort.Slice(toDraw, func(i, j int) bool {
iPix := cellToPix(toDraw[i])
jPix := cellToPix(toDraw[j])
if iPix.Y < jPix.Y {
return true
}
if iPix.Y == jPix.Y {
return iPix.X < jPix.X
}
return false
})
counter := map[string]int{}
for _, pt := range toDraw {
for z := 0; z <= e.state.zIdx; z++ {
if err := e.renderCell(pt.X, pt.Y, z, screen, counter); err != nil {
return err
}
}
}
//log.Printf("%#+v", counter)
return nil
}
func (e *env) renderCell(x, y, z int, screen *ebiten.Image, counter map[string]int) error {
sprites, err := e.area.SpritesForCell(x, y, z)
if err != nil {
return err
}
iso := ebiten.GeoM{}
iso.Translate(-float64(e.state.origin.X), -float64(e.state.origin.Y))
pix := cellToPix(image.Pt(x, y))
iso.Translate(float64(pix.X), float64(pix.Y))
// Taking the Z index away *seems* to draw the object in the correct place.
// FIXME: There are some artifacts, investigate more
iso.Translate(0.0, -float64(z*48.0)) // offset for Z index
// TODO: iso.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
for _, spr := range sprites {
// if _, ok := counter[spr.ID]; !ok {
// counter[spr.ID] = 0
// }
// counter[spr.ID] = counter[spr.ID] + 1
iso.Translate(float64(spr.XOffset), float64(spr.YOffset))
if err := screen.DrawImage(spr.Image, &ebiten.DrawImageOptions{GeoM: iso}); err != nil {
return err
}
iso.Translate(float64(-spr.XOffset), float64(-spr.YOffset))
}
return nil
return e.scenario.Draw(screen)
}
func (e *env) changeOrigin(byX, byY int) func() {
return func() {
e.state.origin.X += byX
e.state.origin.Y += byY
e.scenario.Viewpoint.X += byX
e.scenario.Viewpoint.Y += byY
}
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel
e.state.zoom *= math.Pow(1.2, y)
}
func (e *env) setZIdx(to int) func() {
return func() {
e.state.zIdx = to
e.scenario.ZIdx = to
}
}
const (
cellWidth = 64
cellHeight = 64
)
// Doesn't take the camera or Z level into account
func cellToPix(pt image.Point) image.Point {
return image.Pt(
(pt.X-pt.Y)*cellWidth,
(pt.X+pt.Y)*cellHeight/2,
)
}
// Doesn't take the camera or Z level into account
func pixToCell(pt image.Point) image.Point {
return image.Pt(
pt.Y/cellHeight+pt.X/(cellWidth*2),
pt.Y/cellHeight-pt.X/(cellWidth*2),
)
}

View File

@@ -120,7 +120,7 @@ func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
}
}
return "", os.ErrNotExist
return "", fmt.Errorf("file %q does not exist", filename)
}
func canonical(s string) string {

View File

@@ -1,6 +1,9 @@
package assetstore
import (
"path/filepath"
"strings"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
)
@@ -22,6 +25,12 @@ func (a *AssetStore) Generic() (*data.Generic, error) {
return nil, err
}
// These changes are made so data in generic plays nicer with assetstore
for i, filename := range generic.CampaignMaps {
generic.CampaignMaps[i] =
strings.TrimSuffix(filename, filepath.Ext(filename))
}
a.generic = generic
return generic, nil

View File

@@ -1,12 +1,13 @@
package flow
func (f *Flow) linkBridge() {
// FIXME: sometimes these doors are frozen, depending on game state
// FIXME: sometimes these doors are frozen, depending on ship state, but we
// don't implement that yet.
f.onClick(bridge, "2.1", f.setDriver(briefing)) // TODO: Mission briefing clickable
f.onClick(bridge, "2.1", f.setDriver(briefing)) // Mission briefing clickable
f.onClick(bridge, "2.2", f.setDriver(choices)) // Options door hotspot
f.setFreeze(bridge, "2.4", false) // FIXME: Enter combat door hotspot (!!!)
f.setFreeze(bridge, "2.6", false) // FIXME: Vehicle configure door hotspot
f.onClick(bridge, "2.4", f.playNextScenario()) // Enter combat door hotspot
f.setFreeze(bridge, "2.6", true) // TODO: Vehicle configure door hotspot
f.onClick(bridge, "2.8", f.setDriver(arrange)) // Squads configure door hotspot
// link children

View File

@@ -3,11 +3,15 @@ package flow
import (
"errors"
"fmt"
"log"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/scenario"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui"
)
@@ -19,6 +23,7 @@ type Flow struct {
config *config.Config
current *ui.Driver
drivers map[driverName]*ui.Driver
generic *data.Generic
// Some screens can be returned to from more than one place. Where this is
// the case, instead of hardcoding it, we'll store an entry in here so we
@@ -27,6 +32,11 @@ type Flow struct {
// FIXME: this really suggests wiring everything up at the start is wrong.
returns map[driverName]driverName
// If we're currently playing a scenario, it it placed here
scenario *scenario.Scenario
ship *ship.Ship
exit error
}
@@ -79,12 +89,19 @@ var (
}
)
func New(assets *assetstore.AssetStore, config *config.Config) (*Flow, error) {
func New(assets *assetstore.AssetStore, config *config.Config, ship *ship.Ship) (*Flow, error) {
generic, err := assets.Generic()
if err != nil {
return nil, fmt.Errorf("Failed to read generic data: %v", err)
}
out := &Flow{
assets: assets,
config: config,
generic: generic,
drivers: make(map[driverName]*ui.Driver, len(driverNames)),
returns: make(map[driverName]driverName),
ship: ship,
}
// Load all the drivers upfront
@@ -127,7 +144,20 @@ func (f *Flow) Update(screenX, screenY int) error {
return f.exit
}
return f.current.Update(screenX, screenY)
if f.scenario != nil {
if err := f.scenario.Update(screenX, screenY); err != nil {
return err
}
}
if f.current != nil {
if err := f.current.Update(screenX, screenY); err != nil {
return err
}
}
return nil
}
func (f *Flow) Draw(screen *ebiten.Image) error {
@@ -135,13 +165,31 @@ func (f *Flow) Draw(screen *ebiten.Image) error {
return f.exit
}
return f.current.Draw(screen)
if f.scenario != nil {
if err := f.scenario.Draw(screen); err != nil {
return err
}
}
if f.current != nil {
if err := f.current.Draw(screen); err != nil {
return err
}
}
return nil
}
func (f *Flow) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
if f.current != nil {
return f.current.Cursor()
}
// FIXME: we should get a cursor from current all the time.
return nil, nil, nil
}
func (f *Flow) linkDrivers() {
// linkMain
f.onClick(main, "2.1", f.setDriver(newGame)) // New game
@@ -215,6 +263,23 @@ func (f *Flow) setReturningDriver(from, to driverName) func() {
}
}
func (f *Flow) playNextScenario() func() {
return func() {
log.Printf("Loading scenario: %v", f.ship.NextScenario)
// TODO: we *could* load scenario assets in a separate assetstore to
// make it easier to chuck them away at the end?
scenario, err := scenario.NewScenario(f.assets, f.ship.NextScenario)
if err != nil {
f.exit = err
return
}
f.current = nil // TODO: show the UI for a scenario
f.scenario = scenario
}
}
// from is the child menu, to is the parent
func (f *Flow) returnToLastDriverNow(from driverName) error {
to, ok := f.returns[from]

View File

@@ -29,7 +29,10 @@ func (f *Flow) linkLevelPly() {
// FIXME: we should select a savegame if Mighty Hero is selected here
// FIXME: we should show a movie here. Need an internal SMK player first
// FIXME: we should set up new game state here!
f.onClick(levelPly, "2.7", f.setDriver(bridge)) // Select button
f.onClick(levelPly, "2.7", func() { // Select button
f.ship.NextScenario = f.generic.CampaignMaps[0]
f.setDriverNow(bridge)
})
// Link children
f.linkBridge()

View File

@@ -5,7 +5,6 @@
package ordoor
import (
"errors"
"fmt"
"log"
@@ -15,34 +14,23 @@ import (
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/flow"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui"
)
type gameState int
const (
StateInterface gameState = 1
StateExit gameState = 666
)
var (
errExit = errors.New("User-requested exit action")
)
type Ordoor struct {
assets *assetstore.AssetStore
config *config.Config
music *audio.Player
win *ui.Window
state gameState
nextState gameState
// Relevant to interface state
flow *flow.Flow
// Relevant to campaign state
ship *Ship
ship *ship.Ship
}
func Run(configFile string, overrideX, overrideY int) error {
@@ -75,9 +63,7 @@ func Run(configFile string, overrideX, overrideY int) error {
ordoor := &Ordoor{
assets: assets,
config: cfg,
ship: &Ship{},
state: StateInterface,
nextState: StateInterface,
ship: &ship.Ship{},
}
x, y := cfg.Options.XRes, cfg.Options.YRes
@@ -100,7 +86,7 @@ func Run(configFile string, overrideX, overrideY int) error {
}
if err := ordoor.Run(); err != nil {
return fmt.Errorf("Run returned %v", err)
return fmt.Errorf("Run finished with error: %v", err)
}
return nil
@@ -124,6 +110,8 @@ func (o *Ordoor) Run() error {
// Only one music track can play at a time. This is handled at the toplevel.
// FIXME: should take references from Sounds.dat
// FIXME: music probably properly belongs to flow. This package can just do
// initialization and wire the flow to the ship?
func (o *Ordoor) PlayMusic(name string) error {
if o.music != nil {
if err := o.music.Close(); err != nil {
@@ -152,7 +140,7 @@ func (o *Ordoor) PlayMusic(name string) error {
func (o *Ordoor) setupFlow() error {
o.PlayMusic("music_interface")
flow, err := flow.New(o.assets, o.config)
flow, err := flow.New(o.assets, o.config, o.ship)
if err != nil {
return err
}
@@ -163,22 +151,6 @@ func (o *Ordoor) setupFlow() error {
}
func (o *Ordoor) Update(screenX, screenY int) error {
// Perform state transitions
if o.state != o.nextState {
log.Printf("State transition: %v -> %v", o.state, o.nextState)
switch o.nextState {
case StateExit:
{
return errExit
}
default:
return fmt.Errorf("Unknown state transition: %v -> %v", o.state, o.nextState)
}
}
// State transition is finished, hooray
o.state = o.nextState
// Ensure music is doing the right thing
if o.music != nil && o.music.IsPlaying() != o.config.Options.PlayMusic {
if o.config.Options.PlayMusic {
@@ -189,27 +161,13 @@ func (o *Ordoor) Update(screenX, screenY int) error {
}
}
switch o.state {
case StateInterface:
return o.flow.Update(screenX, screenY)
default:
return fmt.Errorf("Unknown state: %v", o.state)
}
}
func (o *Ordoor) Draw(screen *ebiten.Image) error {
switch o.state {
case StateInterface:
return o.flow.Draw(screen)
default:
return fmt.Errorf("Unknown state: %v", o.state)
}
}
func (o *Ordoor) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
if o.state == StateInterface {
return o.flow.Cursor()
}
return nil, nil, nil
}

134
internal/scenario/draw.go Normal file
View File

@@ -0,0 +1,134 @@
package scenario
import (
"image"
"sort"
"github.com/hajimehoshi/ebiten"
)
func (s *Scenario) Update(screenX, screenY int) error {
s.tick += 1
return nil
}
func (s *Scenario) Draw(screen *ebiten.Image) error {
// Bounds clipping
// http://www.java-gaming.org/index.php?topic=24922.0
// https://stackoverflow.com/questions/892811/drawing-isometric-game-worlds
// https://gamedev.stackexchange.com/questions/25896/how-do-i-find-which-isometric-tiles-are-inside-the-cameras-current-view
sw, sh := screen.Size()
topLeft := pixToCell(s.Viewpoint)
topLeft.X -= 5 // Ensure we paint to every visible section of the screeen.
topLeft.X -= 5 // FIXME: haxxx
bottomRight := pixToCell(image.Pt(s.Viewpoint.X+sw, s.Viewpoint.Y+sh))
bottomRight.X += 5
bottomRight.Y += 5
// X+Y is constant for all tiles in a column
// X-Y is constant for all tiles in a row
// However, the drawing order is odd unless we reorder explicitly.
toDraw := []image.Point{}
for a := topLeft.X + topLeft.Y; a <= bottomRight.X+bottomRight.Y; a++ {
for b := topLeft.X - topLeft.Y; b <= bottomRight.X-bottomRight.Y; b++ {
if b&1 != a&1 {
continue
}
pt := image.Pt((a+b)/2, (a-b)/2)
if !pt.In(s.area.Rect) {
continue
}
toDraw = append(toDraw, pt)
}
}
sort.Slice(toDraw, func(i, j int) bool {
iPix := cellToPix(toDraw[i])
jPix := cellToPix(toDraw[j])
if iPix.Y < jPix.Y {
return true
}
if iPix.Y == jPix.Y {
return iPix.X < jPix.X
}
return false
})
counter := map[string]int{}
for _, pt := range toDraw {
for z := 0; z <= s.ZIdx; z++ {
if err := s.renderCell(pt.X, pt.Y, z, screen, counter); err != nil {
return err
}
}
}
//log.Printf("%#+v", counter)
return nil
}
func (s *Scenario) renderCell(x, y, z int, screen *ebiten.Image, counter map[string]int) error {
sprites, err := s.area.SpritesForCell(x, y, z)
if err != nil {
return err
}
iso := ebiten.GeoM{}
iso.Translate(-float64(s.Viewpoint.X), -float64(s.Viewpoint.Y))
pix := cellToPix(image.Pt(x, y))
iso.Translate(float64(pix.X), float64(pix.Y))
// Taking the Z index away *seems* to draw the object in the correct place.
// FIXME: There are some artifacts, investigate more
iso.Translate(0.0, -float64(z*48.0)) // offset for Z index
// TODO: iso.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
for _, spr := range sprites {
// if _, ok := counter[spr.ID]; !ok {
// counter[spr.ID] = 0
// }
// counter[spr.ID] = counter[spr.ID] + 1
iso.Translate(float64(spr.XOffset), float64(spr.YOffset))
if err := screen.DrawImage(spr.Image, &ebiten.DrawImageOptions{GeoM: iso}); err != nil {
return err
}
iso.Translate(float64(-spr.XOffset), float64(-spr.YOffset))
}
return nil
}
const (
cellWidth = 64
cellHeight = 64
)
// Doesn't take the camera or Z level into account
func cellToPix(pt image.Point) image.Point {
return image.Pt(
(pt.X-pt.Y)*cellWidth,
(pt.X+pt.Y)*cellHeight/2,
)
}
// Doesn't take the camera or Z level into account
func pixToCell(pt image.Point) image.Point {
return image.Pt(
pt.Y/cellHeight+pt.X/(cellWidth*2),
pt.Y/cellHeight-pt.X/(cellWidth*2),
)
}

View File

@@ -0,0 +1,41 @@
// package play takes a map and turns it into a playable scenario
package scenario
import (
"fmt"
"image"
"code.ur.gs/lupine/ordoor/internal/assetstore"
)
type Scenario struct {
area *assetstore.Map
tick int
turn int
// All these must be modified by user actions somehow.
// TODO: extract into the idea of a viewport passed to Update / Draw somehow?
// Or have a separater Drawer for the Scenario?
Viewpoint image.Point // Top-left of the screen
ZIdx int // Currently-viewed Z index
}
func NewScenario(assets *assetstore.AssetStore, name string) (*Scenario, error) {
area, err := assets.Map(name)
if err != nil {
return nil, err
}
// Eager load sprites. TODO: do we really want to do this?
if err := area.LoadSprites(); err != nil {
return nil, fmt.Errorf("Eager-loading sprites failed: %v", err)
}
out := &Scenario{
area: area,
Viewpoint: image.Pt(0, 3000), // FIXME: haxxx
}
return out, nil
}

View File

@@ -1,9 +1,9 @@
package ordoor
package ship
// Ship encapsulates campaign state, including current location in the campaign,
// marines and their stats, supplies, etc.
type Ship struct {
CurrentMission string
NextScenario string
Squads []*Squad
Captain *Character