diff --git a/cmd/view-map/main.go b/cmd/view-map/main.go index 40da688..de8e5d0 100644 --- a/cmd/view-map/main.go +++ b/cmd/view-map/main.go @@ -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), - ) -} diff --git a/internal/assetstore/assetstore.go b/internal/assetstore/assetstore.go index 4316c3c..3c730a2 100644 --- a/internal/assetstore/assetstore.go +++ b/internal/assetstore/assetstore.go @@ -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 { diff --git a/internal/assetstore/data.go b/internal/assetstore/data.go index 4487417..4f64b4c 100644 --- a/internal/assetstore/data.go +++ b/internal/assetstore/data.go @@ -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 diff --git a/internal/flow/bridge.go b/internal/flow/bridge.go index db93d32..0a7884b 100644 --- a/internal/flow/bridge.go +++ b/internal/flow/bridge.go @@ -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 diff --git a/internal/flow/flow.go b/internal/flow/flow.go index 3e46a36..bdeb9c3 100644 --- a/internal/flow/flow.go +++ b/internal/flow/flow.go @@ -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,11 +165,29 @@ 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) { - return f.current.Cursor() + 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() { @@ -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] diff --git a/internal/flow/new_game.go b/internal/flow/new_game.go index 65e72a6..5fae978 100644 --- a/internal/flow/new_game.go +++ b/internal/flow/new_game.go @@ -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() diff --git a/internal/ordoor/ordoor.go b/internal/ordoor/ordoor.go index 608b627..cbb7020 100644 --- a/internal/ordoor/ordoor.go +++ b/internal/ordoor/ordoor.go @@ -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 { @@ -73,11 +61,9 @@ func Run(configFile string, overrideX, overrideY int) error { } ordoor := &Ordoor{ - assets: assets, - config: cfg, - ship: &Ship{}, - state: StateInterface, - nextState: StateInterface, + assets: assets, + config: cfg, + 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) - } + return o.flow.Update(screenX, screenY) } 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) - } + return o.flow.Draw(screen) } func (o *Ordoor) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) { - if o.state == StateInterface { - return o.flow.Cursor() - } - - return nil, nil, nil + return o.flow.Cursor() } diff --git a/internal/scenario/draw.go b/internal/scenario/draw.go new file mode 100644 index 0000000..eb1f72e --- /dev/null +++ b/internal/scenario/draw.go @@ -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), + ) +} diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go new file mode 100644 index 0000000..493ddc8 --- /dev/null +++ b/internal/scenario/scenario.go @@ -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 +} diff --git a/internal/ordoor/ship.go b/internal/ship/ship.go similarity index 97% rename from internal/ordoor/ship.go rename to internal/ship/ship.go index d03e08e..3635662 100644 --- a/internal/ordoor/ship.go +++ b/internal/ship/ship.go @@ -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