From 87e8d15eb15f36a3d04bb9267d6685063958f7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Baasch=20de=20Souza?= Date: Mon, 14 Apr 2025 01:12:04 -0300 Subject: [PATCH 1/2] Prepare model and game logic to provide data for animations --- fortfound_app/src/fortfound_app.gleam | 11 +- fortfound_core/src/fortfound_core/game.gleam | 102 ++++++++++-------- fortfound_core/src/fortfound_core/model.gleam | 20 +++- 3 files changed, 82 insertions(+), 51 deletions(-) diff --git a/fortfound_app/src/fortfound_app.gleam b/fortfound_app/src/fortfound_app.gleam index bcdc84a..96baa8c 100644 --- a/fortfound_app/src/fortfound_app.gleam +++ b/fortfound_app/src/fortfound_app.gleam @@ -5,7 +5,8 @@ import fortfound_core/game.{empty_game, game_from_seed, get_card, make_move} import fortfound_core/model.{ type Card, type Game, type Location, type MajorArcanaFoundation, type MinorArcanaFoundation, BlockingMinorArcanaFoundation, Clubs, Coins, - Column, Cups, Game, HistoryStep, MajorArcana, MinorArcana, Move, State, Swords, + Column, Cups, Game, HistoryStep, MajorArcana, MinorArcana, MoveRequest, State, + Swords, } import fortfound_core/rng.{type Seed} import fortfound_core/scenarios @@ -144,8 +145,8 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ReleasedCard(target: Some(target)) -> { let new_model = case model.selected { Some(Dragging(from: source, ..)) -> - case make_move(model.game, Move(source, target)) { - Ok(game) -> Model(..model, game:, selected: None) + case make_move(model.game, MoveRequest(source, target)) { + Ok(#(_move, game)) -> Model(..model, game:, selected: None) Error(Nil) -> Model(..model, selected: None) } _ -> model @@ -156,8 +157,8 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Clicked(Some(target)) -> { let new_model = case model.selected { Some(Highlighted(location: source, ..)) -> { - case make_move(model.game, Move(source, target)) { - Ok(game) -> Model(..model, game:, selected: None) + case make_move(model.game, MoveRequest(source, target)) { + Ok(#(_move, game)) -> Model(..model, game:, selected: None) Error(Nil) -> Model(..model, selected: None) } } diff --git a/fortfound_core/src/fortfound_core/game.gleam b/fortfound_core/src/fortfound_core/game.gleam index 3216d2a..d6d4ef5 100644 --- a/fortfound_core/src/fortfound_core/game.gleam +++ b/fortfound_core/src/fortfound_core/game.gleam @@ -1,8 +1,10 @@ import fortfound_core/model.{ - type Card, type Game, type Location, type Move, type State, type Suit, - type ValidMove, BlockingMinorArcanaFoundation, Clubs, Coins, Column, Cups, - Game, HistoryStep, MajorArcana, MajorArcanaFoundation, MinorArcana, - MinorArcanaFoundation, Move, State, Swords, ValidMove, + type Card, type FullMove, type Game, type Location, type MoveRequest, + type MoveToFoundation, type PartialMove, type State, type Suit, + BlockingMinorArcanaFoundation, Clubs, Coins, Column, Cups, FullMove, Game, + HistoryStep, MajorArcana, MajorArcanaFoundation, MinorArcana, + MinorArcanaFoundation, MoveRequest, MoveToFoundation, PartialMove, State, + Swords, } import fortfound_core/rng.{type Seed, shuffle} import gleam/bool @@ -163,32 +165,51 @@ fn put_card(state: State, card: Card, target: Location) -> Result(State, Nil) { } } -fn move_card(state: State, move: Move) -> Result(State, Nil) { - use #(card, state) <- result.then(pop_card(state, move.source)) - put_card(state, card, move.target) +fn move_card( + state: State, + request: MoveRequest, +) -> Result(#(PartialMove, State), Nil) { + use #(card, state) <- result.try(pop_card(state, request.source)) + use new_state <- result.try(put_card(state, card, request.target)) + Ok(#(PartialMove(request.source, card, request.target), new_state)) } -fn move_stack(state: State, move: Move) -> Result(State, Nil) { - case move_card(state, move) { - Ok(state_after_one) -> - move_stack(state_after_one, move) - |> result.or(Ok(state_after_one)) - _ -> Error(Nil) +fn move_stack( + state: State, + request: MoveRequest, +) -> Result(#(PartialMove, List(PartialMove), State), Nil) { + use #(first, state_after_first) <- result.try(move_card(state, request)) + + let #(rest, new_state) = case move_stack(state_after_first, request) { + Ok(#(second, others, new_state)) -> #([second, ..others], new_state) + Error(_) -> #([], state_after_first) } + + Ok(#(first, rest, new_state)) } -fn validate_move(state: State, move: Move) -> Result(ValidMove, Nil) { - use <- bool.guard(move.source == move.target, return: Error(Nil)) - use card <- result.then(get_card(state, move.source)) - move_stack(state, move) - |> result.map(apply_colaterals) - |> result.map(ValidMove(card, _)) +fn validate_move( + state: State, + request: MoveRequest, +) -> Result(#(FullMove, State), Nil) { + use <- bool.guard(request.source == request.target, return: Error(Nil)) + + let move_result = move_stack(state, request) + use #(requested, stacked, new_state) <- result.then(move_result) + + let #(to_foundations, new_state) = apply_colaterals(new_state) + Ok(#(FullMove(requested:, stacked:, to_foundations:), new_state)) } -pub fn make_move(game: Game, move: Move) -> Result(Game, Nil) { - use ValidMove(card, new_state) <- result.try(validate_move(game.state, move)) - let history = [HistoryStep(card, game.state), ..game.history] - Ok(Game(..game, state: new_state, history:)) +pub fn make_move( + game: Game, + request: MoveRequest, +) -> Result(#(FullMove, Game), Nil) { + let move_result = validate_move(game.state, request) + use #(move, new_state) <- result.then(move_result) + let history = [HistoryStep(move.requested.card, game.state), ..game.history] + let game = Game(..game, state: new_state, history:) + Ok(#(move, game)) } fn is_won(state: State) -> Bool { @@ -205,19 +226,13 @@ fn is_won(state: State) -> Bool { } } -fn move_combinations( - sources: List(Location), - targets: List(Location), -) -> List(Move) { - { - use source <- list.map(sources) - use target <- list.map(targets) - Move(source, target) - } - |> list.flatten +fn cartesian_product(l1: List(a), l2: List(a)) -> List(#(a, a)) { + use first <- list.flat_map(l1) + use second <- list.map(l2) + #(first, second) } -fn valid_moves(state: State) -> List(ValidMove) { +fn valid_moves(state: State) -> List(#(FullMove, State)) { let #(empty_columns, non_empty_columns) = state.columns |> dict.to_list @@ -236,8 +251,11 @@ fn valid_moves(state: State) -> List(ValidMove) { None -> #(sources, [BlockingMinorArcanaFoundation, ..targets]) } - move_combinations(sources, targets) - |> list.filter_map(validate_move(state, _)) + cartesian_product(sources, targets) + |> list.filter_map(fn(source_target) { + let #(source, target) = source_target + validate_move(state, MoveRequest(source, target)) + }) } fn next_low_major_arcana(state: State) -> Int { @@ -319,14 +337,16 @@ fn move_to_foundation(state: State, card: Card) -> State { } } -fn apply_colaterals(state: State) -> State { +fn apply_colaterals(state: State) -> #(List(MoveToFoundation), State) { case find_ready_for_foundation(state) { Ok(location) -> { let assert Ok(#(card, state_without_card)) = pop_card(state, location) let new_state = state_without_card |> move_to_foundation(card) - apply_colaterals(new_state) + let move = MoveToFoundation(location, card) + let #(other_moves, new_state) = apply_colaterals(new_state) + #([move, ..other_moves], new_state) } - _ -> state + _ -> #([], state) } } @@ -406,9 +426,7 @@ pub fn is_winnable_aux(pending: List(State), previous: Set(State)) -> Bool { False -> { let previous = previous |> set.insert(state) - let next_states = - valid_moves(state) - |> list.map(fn(valid_move) { valid_move.result }) + let next_states = valid_moves(state) |> list.map(pair.second) let pending = next_states diff --git a/fortfound_core/src/fortfound_core/model.gleam b/fortfound_core/src/fortfound_core/model.gleam index d2d2409..6ceb2ad 100644 --- a/fortfound_core/src/fortfound_core/model.gleam +++ b/fortfound_core/src/fortfound_core/model.gleam @@ -60,10 +60,22 @@ pub type Location { BlockingMinorArcanaFoundation } -pub type Move { - Move(source: Location, target: Location) +pub type MoveRequest { + MoveRequest(source: Location, target: Location) } -pub type ValidMove { - ValidMove(moved: Card, result: State) +pub type PartialMove { + PartialMove(source: Location, card: Card, target: Location) +} + +pub type MoveToFoundation { + MoveToFoundation(source: Location, card: Card) +} + +pub type FullMove { + FullMove( + requested: PartialMove, + stacked: List(PartialMove), + to_foundations: List(MoveToFoundation), + ) } From eec0fb6fc9a1f908df0b2fe7ed8307a741ae9a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Baasch=20de=20Souza?= Date: Thu, 17 Apr 2025 02:26:58 -0300 Subject: [PATCH 2/2] Add animations for deck draw, stacked moves and moves to foundations --- fortfound_app/gleam.toml | 1 + fortfound_app/manifest.toml | 2 + fortfound_app/src/fortfound_app.gleam | 505 ++++++++++++++++--- fortfound_app/src/fortfound_app/layout.gleam | 8 + fortfound_core/src/fortfound_core/game.gleam | 2 +- 5 files changed, 441 insertions(+), 77 deletions(-) diff --git a/fortfound_app/gleam.toml b/fortfound_app/gleam.toml index e98f0df..e674164 100644 --- a/fortfound_app/gleam.toml +++ b/fortfound_app/gleam.toml @@ -13,3 +13,4 @@ lustre = { git = "https://github.com/lustre-labs/lustre", ref = "main" } modem = ">= 2.0.1 and < 3.0.0" fortfound_core = { path = "../fortfound_core" } glector = ">= 1.1.0 and < 2.0.0" +lustre_animation = { git = "https://github.com/cauebs/lustre_animation", ref = "main" } diff --git a/fortfound_app/manifest.toml b/fortfound_app/manifest.toml index adcf9af..c68cc90 100644 --- a/fortfound_app/manifest.toml +++ b/fortfound_app/manifest.toml @@ -32,6 +32,7 @@ packages = [ { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, { name = "lustre", version = "4.6.4", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], source = "git", repo = "https://github.com/lustre-labs/lustre", commit = "78ab90353b09539cf918567d0ab631f85da8c85d" }, + { name = "lustre_animation", version = "0.3.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], source = "git", repo = "https://github.com/cauebs/lustre_animation", commit = "7e80b4941a28b85e51759e0065589bb202c5def5" }, { name = "lustre_dev_tools", version = "1.7.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_deque", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_regexp", "gleam_stdlib", "glint", "glisten", "lustre", "mist", "repeatedly", "simplifile", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "7C904F8D69A30240914A17009CD5BB9CE39CDEB31E6CFB7CF4579476CB66C7A4" }, { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, { name = "mist", version = "4.0.7", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "F7D15A1E3232E124C7CE31900253633434E59B34ED0E99F273DEE61CDB573CDD" }, @@ -52,5 +53,6 @@ fortfound_core = { path = "../fortfound_core" } gleam_stdlib = { version = ">= 0.56.0 and < 1.0.0" } glector = { version = ">= 1.1.0 and < 2.0.0" } lustre = { git = "https://github.com/lustre-labs/lustre", ref = "main" } +lustre_animation = { git = "https://github.com/cauebs/lustre_animation", ref = "main" } lustre_dev_tools = { version = ">= 1.7.0 and < 2.0.0" } modem = { version = ">= 2.0.1 and < 3.0.0" } diff --git a/fortfound_app/src/fortfound_app.gleam b/fortfound_app/src/fortfound_app.gleam index 96baa8c..71e4f15 100644 --- a/fortfound_app/src/fortfound_app.gleam +++ b/fortfound_app/src/fortfound_app.gleam @@ -1,26 +1,29 @@ import fortfound_app/layout.{type Layout} import fortfound_app/palette import fortfound_app/special_characters.{suit_icon} -import fortfound_core/game.{empty_game, game_from_seed, get_card, make_move} +import fortfound_core/game.{empty_game, game_from_seed, get_card} import fortfound_core/model.{ - type Card, type Game, type Location, type MajorArcanaFoundation, - type MinorArcanaFoundation, BlockingMinorArcanaFoundation, Clubs, Coins, - Column, Cups, Game, HistoryStep, MajorArcana, MinorArcana, MoveRequest, State, - Swords, + type Card, type FullMove, type Game, type Location, type MajorArcanaFoundation, + type MinorArcanaFoundation, type MoveToFoundation, type PartialMove, type Suit, + BlockingMinorArcanaFoundation, Clubs, Coins, Column, Cups, Game, HistoryStep, + MajorArcana, MinorArcana, MoveRequest, State, Swords, } import fortfound_core/rng.{type Seed} import fortfound_core/scenarios -import gleam/dict +import gleam/dict.{type Dict} import gleam/dynamic/decode import gleam/float import gleam/int import gleam/list import gleam/option.{type Option, None, Some} +import gleam/order +import gleam/pair import gleam/result import gleam/string import gleam/uri.{type Uri} import glector.{type Vector2, Vector2} import lustre +import lustre/animation.{type Animation, type Animations} import lustre/attribute.{type Attribute, attribute as attr} import lustre/effect.{type Effect} import lustre/element.{type Element} @@ -45,8 +48,24 @@ type SelectedCard { Highlighted(card: Card, location: Location) } +type AnimatedPosition { + AnimatedPosition(xy: Vector2, z: Int) +} + +type CardAnimations = + Animations(Card, AnimatedPosition) + +type CardAnimation = + Animation(AnimatedPosition) + type Model { - Model(game: Game, selected: Option(SelectedCard), displaying_help: Bool) + Model( + game: Game, + selected: Option(SelectedCard), + animations: CardAnimations, + displaying_help: Bool, + layout: Layout, + ) } fn init(_flags) -> #(Model, Effect(Msg)) { @@ -56,7 +75,16 @@ fn init(_flags) -> #(Model, Effect(Msg)) { |> result.map(game_from_seed) |> result.lazy_unwrap(empty_game) - #(Model(game:, selected: None, displaying_help: False), effect.none()) + #( + Model( + game:, + selected: None, + animations: animation.new(), + displaying_help: False, + layout: layout.get_layout(), + ), + effect.none(), + ) } fn parse_seed(uri: Uri) -> Result(Seed, Nil) { @@ -87,29 +115,36 @@ type Msg { ReleasedCard(target: Option(Location)) Clicked(Option(Location)) UndoMove + AnimationTick(Float) } fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { - case msg { - RequestedNewGame(scenario) -> { + let input_allowed = list.is_empty(animation.ids(model.animations)) + + let #(new_model, effect) = case input_allowed, msg { + _, RequestedNewGame(scenario) -> { let seed = case scenario { Random -> scenarios.random_winnable_scenario() Daily -> scenarios.current_daily_scenario() Specific(seed) -> seed } - let new_model = Model(..model, game: game_from_seed(seed), selected: None) + let game = game_from_seed(seed) + let new_model = model |> start_game(game) #(new_model, set_seed_in_uri(seed)) } - PressedRestart -> { + _, PressedRestart -> { let new_model = case model.game.seed { None -> Model(..model, game: empty_game(), selected: None) - Some(seed) -> Model(..model, game: game_from_seed(seed), selected: None) + Some(seed) -> { + let game = game_from_seed(seed) + model |> start_game(game) + } } #(new_model, effect.none()) } - GrabbedCard(source:, position:, pointer_offset:) -> { + True, GrabbedCard(source:, position:, pointer_offset:) -> { let grabbed_card = get_card(model.game.state, source) let new_model = case model.selected, grabbed_card { None, Ok(card) -> { @@ -122,7 +157,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { #(new_model, effect.none()) } - MovedPointer(position) -> { + True, MovedPointer(position) -> { let new_model = case model.selected { Some(Dragging(..) as dragging) -> { let position = glector.add(position, dragging.pointer_offset) @@ -134,7 +169,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { #(new_model, effect.none()) } - ReleasedCard(target: None) -> { + True, ReleasedCard(target: None) -> { let new_model = case model.selected { Some(Dragging(..)) -> Model(..model, selected: None) _ -> model @@ -142,26 +177,18 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { #(new_model, effect.none()) } - ReleasedCard(target: Some(target)) -> { + True, ReleasedCard(target: Some(target)) -> { let new_model = case model.selected { - Some(Dragging(from: source, ..)) -> - case make_move(model.game, MoveRequest(source, target)) { - Ok(#(_move, game)) -> Model(..model, game:, selected: None) - Error(Nil) -> Model(..model, selected: None) - } + Some(Dragging(from: source, ..)) -> make_move(model, source, target) _ -> model } #(new_model, effect.none()) } - Clicked(Some(target)) -> { + True, Clicked(Some(target)) -> { let new_model = case model.selected { - Some(Highlighted(location: source, ..)) -> { - case make_move(model.game, MoveRequest(source, target)) { - Ok(#(_move, game)) -> Model(..model, game:, selected: None) - Error(Nil) -> Model(..model, selected: None) - } - } + Some(Highlighted(location: source, ..)) -> + make_move(model, source, target) _ -> { case get_card(model.game.state, target) { @@ -176,24 +203,48 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { #(new_model, effect.none()) } - Clicked(None) -> #(Model(..model, selected: None), effect.none()) + True, Clicked(None) -> #(Model(..model, selected: None), effect.none()) - UndoMove -> { + _, UndoMove -> { let new_model = case model.game.history { [HistoryStep(state_before:, ..), ..history] -> { let game = Game(..model.game, state: state_before, history:) - Model(..model, game:) + Model(..model, game:, animations: animation.new()) } [] -> model } #(new_model, effect.none()) } - PressedHelp -> #( - Model(..model, displaying_help: !model.displaying_help) |> echo, + _, PressedHelp -> #( + Model(..model, displaying_help: !model.displaying_help), + effect.none(), + ) + + _, AnimationTick(t) -> #( + Model(..model, animations: animation.tick(model.animations, t)), effect.none(), ) + + False, _ -> #(model, effect.none()) } + + let animation_tick_effect = + animation.effect(new_model.animations, AnimationTick) + #(new_model, effect.batch([effect, animation_tick_effect])) +} + +fn start_game(model: Model, new_game: Game) -> Model { + Model( + ..model, + game: new_game, + selected: None, + animations: animation.new() + |> animation.schedule_many(card_draw_animations( + new_game.state.columns, + model.layout, + )), + ) } fn set_seed_in_uri(seed: Seed) -> Effect(Msg) { @@ -201,6 +252,245 @@ fn set_seed_in_uri(seed: Seed) -> Effect(Msg) { modem.push("", Some(query), None) } +fn card_draw_animations( + columns: Dict(Int, List(Card)), + layout: Layout, +) -> List(#(Card, CardAnimation)) { + let start = Vector2(layout.column_x(5, layout), layout.foundations_y) + + columns + |> dict.to_list + |> list.sort(fn(column1, column2) { int.compare(column1.0, column2.0) }) + |> list.map(fn(column) { + let #(column_index, cards) = column + + cards + |> list.reverse + |> list.index_map(fn(card, row) { + let stop = + Vector2( + layout.column_x(column_index, layout), + layout.tableau_card_y(row, layout), + ) + + #(card, stop) + }) + }) + |> list.transpose + |> list.flatten + |> list.index_map(fn(card_and_stop, index) { + let #(card, stop) = card_and_stop + + let interpolator = fn(t) { + let xy = glector.lerp(start, stop, t) + AnimatedPosition(xy:, z: -index) + } + let delay = 50.0 *. int.to_float(index) + let duration = 200.0 + + let animation = + animation.create_delayed(after: delay, with: interpolator, for: duration) + + #(card, animation) + }) +} + +fn stacked_card_move_animation( + move: PartialMove, + game_after_move: Game, + index_in_stack: Int, + moved_stack_size: Int, + layout: Layout, +) -> #(Card, CardAnimation) { + let assert #(Column(source_column), Column(target_column)) = #( + move.source, + move.target, + ) + + let assert Ok(source_column_size) = + game_after_move.state.columns + |> dict.get(source_column) + |> result.map(list.length) + + let assert Ok(target_column_size) = + game_after_move.state.columns + |> dict.get(target_column) + |> result.map(list.length) + + let row_offset = moved_stack_size - index_in_stack + let source_row = source_column_size + row_offset - 1 + let target_row = target_column_size - row_offset + + let start = + AnimatedPosition( + xy: Vector2( + layout.column_x(source_column, layout), + layout.tableau_card_y(source_row, layout), + ), + z: moved_stack_size - index_in_stack, + ) + + let stop = + AnimatedPosition( + xy: Vector2( + layout.column_x(target_column, layout), + layout.tableau_card_y(target_row, layout), + ), + z: index_in_stack, + ) + + let interpolator = stacked_card_move_interpolator(start, stop) + let delay = 100.0 *. int.to_float(index_in_stack) + let duration = 200.0 + + let animation = + animation.create_delayed(after: delay, with: interpolator, for: duration) + + #(move.card, animation) +} + +fn stacked_card_move_interpolator( + start: AnimatedPosition, + stop: AnimatedPosition, +) -> fn(Float) -> AnimatedPosition { + fn(t) { + let curvature = 0.5 + + let xy = + glector.lerp(start.xy, stop.xy, t) + |> glector.add(Vector2(0.0, curvature *. t *. { 1.0 -. t })) + + let z = + { stop.z - start.z } + |> int.to_float + |> float.multiply(t) + |> float.round + |> int.add(start.z) + + AnimatedPosition(xy:, z:) + } +} + +fn move_to_foundation_animation( + move: MoveToFoundation, + game_after_move: Game, + index: Int, + row_offset: Int, + layout: Layout, +) -> #(Card, CardAnimation) { + let start = case move.source { + BlockingMinorArcanaFoundation -> { + let foundation_center = layout.minor_arcana_foundation_center(layout) + layout.center_to_origin(foundation_center, layout.card_size) + } + Column(i) -> { + let assert Ok(column_size) = + game_after_move.state.columns + |> dict.get(i) + |> result.map(list.length) + Vector2( + x: layout.column_x(i, layout), + y: layout.tableau_card_y(column_size + row_offset, layout), + ) + } + } + + let assert Ok(stop_x) = case move.card { + MajorArcana(value) -> { + layout.major_arcana_foundation_xs(layout) + |> list.drop(value) + |> list.first + } + MinorArcana(suit:, ..) -> { + layout.minor_arcana_foundation_xs(layout) + |> list.drop(case suit { + Clubs -> 0 + Coins -> 1 + Cups -> 2 + Swords -> 3 + }) + |> list.first + } + } + let stop = Vector2(stop_x, layout.foundations_y) + + let interpolator = fn(t) { + let xy = glector.lerp(start, stop, t *. t) + AnimatedPosition(xy:, z: 0) + } + let delay = 200.0 *. int.to_float(index) + let duration = 400.0 + + let animation = + animation.create_delayed(after: delay, with: interpolator, for: duration) + + #(move.card, animation) +} + +fn animate_full_move( + game_after_move: Game, + layout: Layout, + animations: CardAnimations, + move: FullMove, +) -> CardAnimations { + let new_animations = + [ + move.stacked + |> list.index_map(fn(partial_move, index_in_stack) { + stacked_card_move_animation( + partial_move, + game_after_move, + index_in_stack, + list.length(move.stacked), + layout, + ) + }), + move.to_foundations + |> list.sort(by: fn(move1, move2) { + case move1.source, move2.source { + BlockingMinorArcanaFoundation, BlockingMinorArcanaFoundation -> + order.Eq + BlockingMinorArcanaFoundation, Column(_) -> order.Lt + Column(_), BlockingMinorArcanaFoundation -> order.Gt + Column(i1), Column(i2) -> int.compare(i1, i2) + } + }) + |> list.group(by: fn(move) { move.source }) + |> dict.values + |> list.flat_map(fn(moves_from_same_location) { + moves_from_same_location + |> list.index_map(fn(move, i) { + #(move, list.length(moves_from_same_location) - 1 + i) + }) + }) + |> list.index_map(fn(move_and_offset, index) { + let #(move, row_offset) = move_and_offset + move_to_foundation_animation( + move, + game_after_move, + index, + row_offset, + layout, + ) + }), + ] + |> list.flatten + + animations |> animation.schedule_many(new_animations) +} + +fn make_move(model: Model, source: Location, target: Location) -> Model { + case game.make_move(model.game, MoveRequest(source, target)) { + Ok(#(move, game_after_move)) -> { + let animations = + animate_full_move(game_after_move, model.layout, model.animations, move) + Model(..model, game: game_after_move, selected: None, animations:) + } + + Error(Nil) -> Model(..model, selected: None) + } +} + fn view(model: Model) -> Element(Msg) { let State( major_arcana_foundation: major, @@ -208,27 +498,29 @@ fn view(model: Model) -> Element(Msg) { minor_arcana_foundation: minor, ) = model.game.state - let layout = layout.get_layout() - case model.displaying_help { True -> view_help() False -> [ case model.selected { Some(Dragging(card:, position:, ..)) -> - view_dragged_card(card, position, layout) + view_dragged_card(card, position, model.layout) _ -> element.none() }, - view_major_arcana_foundation(major, layout), - view_buttons(layout), + view_animated_cards(model.animations, model.layout), + view_major_arcana_foundation(major, model.layout, model.animations), + view_buttons(model.layout), case model.game.history { - [HistoryStep(moved:, ..), ..] -> view_undo_button(moved, layout) + [HistoryStep(moved:, ..), ..] -> view_undo_button(moved, model.layout) _ -> element.none() }, - view_minor_arcana_foundation(minor, layout, model.selected), - ..columns - |> dict.to_list - |> list.map(view_column(_, layout, model.selected)) + view_minor_arcana_foundation( + minor, + model.layout, + model.selected, + model.animations, + ), + view_columns(columns, model.layout, model.selected, model.animations), ] |> list.reverse |> svg(2000, 1000) @@ -567,71 +859,83 @@ fn view_undo_button(card: Card, layout: Layout) -> Element(Msg) { fn view_major_arcana_foundation( foundation: MajorArcanaFoundation, layout: Layout, + animations: CardAnimations, ) -> Element(Msg) { let column_xs = layout.major_arcana_foundation_xs(layout) + let view_cards = fn(cards: List(#(Card, Float))) -> List(Element(Msg)) { + cards + |> list.filter_map(fn(card_and_x) { + let #(card, x) = card_and_x + case is_animating(card, animations) { + True -> Error(Nil) + False -> { + let position = Vector2(x, layout.foundations_y) + Ok(view_card(card:, position:, scale: 1.0, layout:, loc: None)) + } + } + }) + } + + let assert Ok(low_slot_x) = list.first(column_xs) + let low_slot = + view_slot(Vector2(low_slot_x, layout.foundations_y), layout, None) + let lows = case foundation.low { Some(low) -> { list.range(0, low) |> list.map(MajorArcana) |> list.zip(column_xs) - |> list.map(fn(card_and_x) { - let #(card, x) = card_and_x - let position = Vector2(x, layout.foundations_y) - view_card(card:, position:, scale: 1.0, layout:, loc: None) - }) + |> view_cards } None -> { - let assert Ok(x) = list.first(column_xs) - let position = Vector2(x, layout.foundations_y) - [view_slot(position, layout, None)] + [] } } + let assert Ok(high_slot_x) = list.last(column_xs) + let high_slot = + view_slot(Vector2(high_slot_x, layout.foundations_y), layout, None) + let highs = case foundation.high { Some(high) -> { list.range(21, high) |> list.map(MajorArcana) |> list.zip(list.reverse(column_xs)) - |> list.map(fn(card_and_x) { - let #(card, x) = card_and_x - let position = Vector2(x, layout.foundations_y) - view_card(card:, position:, scale: 1.0, layout:, loc: None) - }) + |> view_cards } None -> { - let assert Ok(x) = list.last(column_xs) - let position = Vector2(x, layout.foundations_y) - [view_slot(position, layout, None)] + [] } } - svg.g([], list.append(lows, highs)) + svg.g([], list.append([low_slot, ..lows], [high_slot, ..highs])) } fn view_minor_arcana_foundation( foundation: MinorArcanaFoundation, layout: Layout, selected: Option(SelectedCard), + animations: CardAnimations, ) -> Element(Msg) { let column_xs = layout.minor_arcana_foundation_xs(layout) let cards = [ - MinorArcana(Clubs, foundation.clubs), - MinorArcana(Coins, foundation.coins), - MinorArcana(Cups, foundation.cups), - MinorArcana(Swords, foundation.swords), + highest_value_not_animating(Clubs, foundation.clubs, animations), + highest_value_not_animating(Coins, foundation.coins, animations), + highest_value_not_animating(Cups, foundation.cups, animations), + highest_value_not_animating(Swords, foundation.swords, animations), ] - let center = - Vector2(float.sum(column_xs) /. 4.0, layout.foundations_y) - |> glector.add(glector.scale(layout.card_size, 0.5)) - let blocker_or_collider = case foundation.blocker, selected { Some(blocker), Some(Dragging(card: dragging, ..)) if blocker == dragging -> element.none() - Some(blocker), _ -> view_blocker(blocker, center, layout) + Some(blocker), _ -> + case is_animating(blocker, animations) { + True -> element.none() + False -> view_blocker(blocker, layout) + } None, _ -> { let assert Ok(x) = list.first(column_xs) @@ -668,11 +972,22 @@ fn view_minor_arcana_foundation( svg.g([], elements) } -fn view_blocker( - card: Card, - foundation_center: Vector2, - layout: Layout, -) -> Element(Msg) { +fn highest_value_not_animating( + suit: Suit, + max_value: Int, + animations: CardAnimations, +) -> Card { + let assert Ok(card) = + list.range(1, max_value) + |> list.map(MinorArcana(suit:, value: _)) + |> list.filter(fn(card) { !list.contains(animation.ids(animations), card) }) + |> list.last + + card +} + +fn view_blocker(card: Card, layout: Layout) -> Element(Msg) { + let foundation_center = layout.minor_arcana_foundation_center(layout) let position = layout.center_to_origin(foundation_center, layout.card_size) svg.g([rotate(foundation_center, 90.0)], [ @@ -696,10 +1011,23 @@ fn rotate(position: Vector2, degrees: Float) -> Attribute(Msg) { attr("transform", "rotate(" <> rotation_values <> ")") } +fn view_columns( + columns: Dict(Int, List(Card)), + layout: Layout, + selected: Option(SelectedCard), + animations: CardAnimations, +) -> Element(Msg) { + columns + |> dict.to_list + |> list.map(view_column(_, layout, selected, animations)) + |> svg.g([], _) +} + fn view_column( column: #(Int, List(Card)), layout: Layout, selected: Option(SelectedCard), + animations: CardAnimations, ) -> Element(Msg) { let #(column_index, cards) = column @@ -719,7 +1047,11 @@ fn view_column( let offset = Vector2(0.0, layout.stacked_card_y_offset) Ok(Vector2(x, y) |> glector.add(offset)) } - _ -> Ok(Vector2(x, y)) + _ -> + case is_animating(card, animations) { + True -> Error(Nil) + False -> Ok(Vector2(x, y)) + } } let interactable = row == list.length(cards) - 1 #(card, position, interactable) @@ -736,3 +1068,24 @@ fn view_column( svg.g([], [slot, ..cards]) } + +fn is_animating(card: Card, animations: CardAnimations) -> Bool { + animation.ids(animations) |> list.contains(card) +} + +fn view_animated_cards( + animations: CardAnimations, + layout: Layout, +) -> Element(Msg) { + animation.ids(animations) + |> list.map(fn(card) { + let assert Ok(position) = animations |> animation.value(card) + #( + position.z, + view_card(card:, position: position.xy, scale: 1.0, layout:, loc: None), + ) + }) + |> list.sort(fn(a, b) { int.compare(pair.first(a), pair.first(b)) }) + |> list.map(pair.second) + |> svg.g([], _) +} diff --git a/fortfound_app/src/fortfound_app/layout.gleam b/fortfound_app/src/fortfound_app/layout.gleam index e7e591d..b6221e8 100644 --- a/fortfound_app/src/fortfound_app/layout.gleam +++ b/fortfound_app/src/fortfound_app/layout.gleam @@ -1,3 +1,4 @@ +import gleam/float import gleam/int import gleam/list import glector.{type Vector2, Vector2} @@ -111,6 +112,13 @@ pub fn minor_arcana_foundation_xs(layout: Layout) -> List(Float) { list.range(7, 10) |> list.map(column_x(_, layout)) } +pub fn minor_arcana_foundation_center(layout: Layout) -> Vector2 { + let column_xs = minor_arcana_foundation_xs(layout) + + Vector2(float.sum(column_xs) /. 4.0, layout.foundations_y) + |> glector.add(glector.scale(layout.card_size, 0.5)) +} + pub fn button_positions(layout: Layout) -> List(Vector2) { let left = column_x(4, layout) let right = column_x(6, layout) diff --git a/fortfound_core/src/fortfound_core/game.gleam b/fortfound_core/src/fortfound_core/game.gleam index d6d4ef5..c42190b 100644 --- a/fortfound_core/src/fortfound_core/game.gleam +++ b/fortfound_core/src/fortfound_core/game.gleam @@ -80,7 +80,7 @@ fn are_stackable(c1: Card, c2: Card) -> Bool { } } -fn get_column(state: State, index: Int) -> List(Card) { +pub fn get_column(state: State, index: Int) -> List(Card) { let assert Ok(column) = dict.get(state.columns, index) column }